Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
fea8384e60 | ||
|
|
de8813da3e | ||
|
|
0877842107 | ||
|
|
a5cbbffc20 | ||
|
|
ffb7be602f | ||
|
|
1d62aca2fb | ||
|
|
9bf7e72a6c | ||
|
|
f8b5dcf300 | ||
|
|
072158f4ed | ||
|
|
898d2f5959 | ||
|
|
a7aec80f39 | ||
|
|
0e313db2e3 | ||
|
|
3e647cd44b | ||
|
|
54af256e91 | ||
|
|
ce413cf6bc |
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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -50,3 +51,6 @@ next-env.d.ts
|
||||
/data
|
||||
.release-years-migrated
|
||||
.covers-migrated
|
||||
docker-compose.yml
|
||||
scripts/scrape-bahn-expert-statements.js
|
||||
docs/bahn-expert-statements.txt
|
||||
|
||||
54
Caddyfile
Normal file
54
Caddyfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# Caddy-Konfiguration für Hördle
|
||||
# Root-Domains: hoerdle.de und hördle.de (xn--hrdle-jua.de)
|
||||
# Hinweis: Diese Konfiguration funktioniert nur für Root-Domains, nicht für Subdomains
|
||||
# Für Subdomains wären Wildcard-Zertifikate mit DNS-01 Challenge nötig
|
||||
|
||||
# Domain 1: hoerdle.de (ASCII)
|
||||
hoerdle.de {
|
||||
# TLS mit automatischer HTTP-01 Challenge (funktioniert nur für Root-Domain)
|
||||
# Caddy verwendet automatisch Let's Encrypt
|
||||
|
||||
# Upload-Limit: 50MB (wie in nginx.conf.example)
|
||||
request_body {
|
||||
max_size 50MB
|
||||
}
|
||||
|
||||
# Reverse Proxy zu hoerdle Container
|
||||
reverse_proxy hoerdle:3000 {
|
||||
# HTTP/1.1 für WebSocket Support
|
||||
transport http {
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP zu HTTPS Redirect
|
||||
@http {
|
||||
protocol http
|
||||
}
|
||||
redir @http https://{host}{uri} permanent
|
||||
}
|
||||
|
||||
# Domain 2: hördle.de (Punycode: xn--hrdle-jua.de)
|
||||
xn--hrdle-jua.de {
|
||||
# TLS mit automatischer HTTP-01 Challenge (funktioniert nur für Root-Domain)
|
||||
# Caddy verwendet automatisch Let's Encrypt
|
||||
|
||||
# Upload-Limit: 50MB
|
||||
request_body {
|
||||
max_size 50MB
|
||||
}
|
||||
|
||||
# Reverse Proxy zu hoerdle Container
|
||||
reverse_proxy hoerdle:3000 {
|
||||
# HTTP/1.1 für WebSocket Support
|
||||
transport http {
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP zu HTTPS Redirect
|
||||
@http {
|
||||
protocol http
|
||||
}
|
||||
redir @http https://{host}{uri} permanent
|
||||
}
|
||||
47
Dockerfile
47
Dockerfile
@@ -13,18 +13,62 @@ RUN npm ci
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Accept version as build argument (optional)
|
||||
ARG APP_VERSION=""
|
||||
|
||||
# Install git to extract version information
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Extract version: use build arg if provided, otherwise get from git, fallback to package.json
|
||||
RUN if [ -n "$APP_VERSION" ]; then \
|
||||
echo "$APP_VERSION" > /tmp/version.txt; \
|
||||
else \
|
||||
(git describe --tags --always 2>/dev/null || \
|
||||
(grep -o '"version": "[^"]*"' package.json 2>/dev/null | cut -d'"' -f4 | sed 's/^/v/') || \
|
||||
echo "dev") > /tmp/version.txt; \
|
||||
fi && \
|
||||
echo "Building version: $(cat /tmp/version.txt)"
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Suppress baseline-browser-mapping warning about old data (informational only)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Generate Prisma Client
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
RUN node_modules/.bin/prisma generate
|
||||
|
||||
# White Label Build Arguments
|
||||
ARG NEXT_PUBLIC_APP_NAME
|
||||
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
||||
ARG NEXT_PUBLIC_DOMAIN
|
||||
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||
ARG NEXT_PUBLIC_THEME_COLOR
|
||||
ARG NEXT_PUBLIC_BACKGROUND_COLOR
|
||||
ARG NEXT_PUBLIC_CREDITS_ENABLED
|
||||
ARG NEXT_PUBLIC_CREDITS_TEXT
|
||||
ARG NEXT_PUBLIC_CREDITS_LINK_TEXT
|
||||
ARG NEXT_PUBLIC_CREDITS_LINK_URL
|
||||
|
||||
# Pass env vars to build
|
||||
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
||||
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
||||
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
|
||||
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
||||
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
||||
ENV NEXT_PUBLIC_CREDITS_ENABLED=$NEXT_PUBLIC_CREDITS_ENABLED
|
||||
ENV NEXT_PUBLIC_CREDITS_TEXT=$NEXT_PUBLIC_CREDITS_TEXT
|
||||
ENV NEXT_PUBLIC_CREDITS_LINK_TEXT=$NEXT_PUBLIC_CREDITS_LINK_TEXT
|
||||
ENV NEXT_PUBLIC_CREDITS_LINK_URL=$NEXT_PUBLIC_CREDITS_LINK_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
@@ -53,6 +97,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
# Create uploads directory and set permissions
|
||||
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
|
||||
|
||||
# Copy version file from builder
|
||||
COPY --from=builder /tmp/version.txt /app/version.txt
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
10
Dockerfile.caddy
Normal file
10
Dockerfile.caddy
Normal file
@@ -0,0 +1,10 @@
|
||||
# Dockerfile für Caddy mit GoDaddy DNS-Provider Plugin
|
||||
FROM caddy:2-builder AS builder
|
||||
|
||||
RUN xcaddy build \
|
||||
--with github.com/caddy-dns/godaddy
|
||||
|
||||
FROM caddy:2
|
||||
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
|
||||
105
README.md
105
README.md
@@ -4,6 +4,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
|
||||
## Features
|
||||
|
||||
- **🌍 Mehrsprachigkeit (i18n):** Vollständige Unterstützung für Deutsch und Englisch mit automatischer Sprachumleitung und lokalisierten Inhalten.
|
||||
- **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer.
|
||||
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
|
||||
- **Admin Dashboard:**
|
||||
@@ -14,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Bearbeitung von Metadaten.
|
||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||
- 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:**
|
||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||
@@ -42,6 +44,54 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Manuelle Speicherung mit visueller Bestätigung.
|
||||
- **News & Announcements:**
|
||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
||||
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||
- Verwaltung über das Admin-Dashboard.
|
||||
- **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
|
||||
|
||||
@@ -49,13 +99,15 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
|
||||
- **Start-Punktestand:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||
- **Überspringen (Skip):** -5 Punkte
|
||||
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
||||
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
||||
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
||||
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
|
||||
|
||||
**Hinweis:** Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die automatische Verlängerung des Audio-Snippets (unlockSteps) abgezogen, um die Verwendung dieses Hilfsmittels zu reflektieren.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
@@ -87,12 +139,14 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000`.
|
||||
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/en` um).
|
||||
|
||||
## Deployment mit Docker
|
||||
|
||||
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
|
||||
👉 **[White Labeling mit Docker? Hier klicken!](docs/WHITE_LABEL.md#docker-deployment)**
|
||||
|
||||
1. **Vorbereitung:**
|
||||
Kopiere die Beispiel-Konfiguration:
|
||||
```bash
|
||||
@@ -107,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_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||
- `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:**
|
||||
```bash
|
||||
@@ -121,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.
|
||||
|
||||
4. **Admin-Zugang:**
|
||||
- URL: `/admin`
|
||||
- URL: `/de/admin` oder `/en/admin`
|
||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||
|
||||
5. **Special Curation & Scheduling verwenden:**
|
||||
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:
|
||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||
- **Optional:** Trage einen Kurator ein.
|
||||
- Weise Songs dem Special zu (über die Song-Bibliothek).
|
||||
- Klicke auf "Curate" neben dem Special.
|
||||
- Nutze den Waveform-Editor um den perfekten Ausschnitt zu wählen:
|
||||
- **Klicken:** Positioniert die Selektion
|
||||
- **Hovern:** Zeigt Vorschau der neuen Position
|
||||
- **Zoom:** 🔍+ / 🔍− Buttons für detaillierte Ansicht
|
||||
- **Pan:** ← / → Buttons zum Verschieben der Ansicht
|
||||
- **Segment-Playback:** Teste einzelne Puzzle-Abschnitte
|
||||
- **Save:** Speichere Änderungen mit dem grünen Button
|
||||
- Die eigentliche Kuratierung (Auswahl des Ausschnitts) findet im **Kuratoren-Dashboard** statt:
|
||||
- Logge dich als Kurator ein und gehe zu `/de/curator` oder `/en/curator`.
|
||||
- Klicke im Dashboard auf **„Curate Specials“**, um eine Liste deiner zugewiesenen Specials zu sehen.
|
||||
- Öffne ein Special und nutze dort den Waveform-Editor, um den perfekten Ausschnitt zu wählen:
|
||||
- **Klicken:** Positioniert die Selektion
|
||||
- **Hovern:** Zeigt Vorschau der neuen Position
|
||||
- **Zoom:** 🔍+ / 🔍− Buttons für detaillierte Ansicht
|
||||
- **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.
|
||||
- Auf der Startseite werden zukünftige Specials unter "Coming soon" angezeigt (mit Datum und Kurator).
|
||||
|
||||
@@ -192,12 +260,12 @@ Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die A
|
||||
|
||||
### Genre-spezifische Einbindung
|
||||
|
||||
Einzelne Genres können direkt eingebunden werden:
|
||||
Einzelne Genres können direkt eingebunden werden (mit Locale-Präfix):
|
||||
|
||||
```html
|
||||
<!-- Rock Genre -->
|
||||
<!-- Rock Genre (Deutsch) -->
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/Rock"
|
||||
src="https://hoerdle.elpatron.me/de/Rock"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
@@ -205,9 +273,9 @@ Einzelne Genres können direkt eingebunden werden:
|
||||
title="Hördle Rock Quiz">
|
||||
</iframe>
|
||||
|
||||
<!-- Pop Genre -->
|
||||
<!-- Pop Genre (Englisch) -->
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/Pop"
|
||||
src="https://hoerdle.elpatron.me/en/Pop"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
@@ -221,8 +289,9 @@ Einzelne Genres können direkt eingebunden werden:
|
||||
Auch thematische Specials können direkt eingebettet werden:
|
||||
|
||||
```html
|
||||
<!-- Weihnachtslieder (Deutsch) -->
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
|
||||
src="https://hoerdle.elpatron.me/de/special/Weihnachtslieder"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import Game from '@/components/Game';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ genre: string }>;
|
||||
}
|
||||
|
||||
export default async function GenrePage({ params }: PageProps) {
|
||||
const { genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
// Check if genre exists and is active
|
||||
const currentGenre = await prisma.genre.findUnique({
|
||||
where: { name: decodedGenre }
|
||||
});
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const now = new Date();
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
||||
const isEnded = s.endDate && s.endDate < now;
|
||||
return isStarted && !isEnded;
|
||||
});
|
||||
|
||||
const upcomingSpecials = specials.filter(s => {
|
||||
return s.launchDate && s.launchDate > now;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${g.name}`}
|
||||
style={{
|
||||
fontWeight: g.name === decodedGenre ? 'bold' : 'normal',
|
||||
textDecoration: g.name === decodedGenre ? 'underline' : 'none',
|
||||
color: g.name === decodedGenre ? 'black' : '#4b5563'
|
||||
}}
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Specials */}
|
||||
{activeSpecials.map(s => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/special/${s.name}`}
|
||||
style={{
|
||||
color: '#be185d', // Pink-700
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Specials */}
|
||||
{upcomingSpecials.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
||||
Coming soon: {upcomingSpecials.map(s => (
|
||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||
★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: process.env.TZ
|
||||
}) : ''})
|
||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
app/[locale]/[genre]/page.tsx
Normal file
165
app/[locale]/[genre]/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import Game from '@/components/Game';
|
||||
import NewsSection from '@/components/NewsSection';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string; genre: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
// Fetch genre to get localized name
|
||||
const allGenres = await prisma.genre.findMany();
|
||||
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
return await generateBaseMetadata(locale, genre);
|
||||
}
|
||||
|
||||
const genreName = getLocalizedValue(currentGenre.name, locale);
|
||||
const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale);
|
||||
|
||||
const title = locale === 'de'
|
||||
? `${genreName} - Hördle`
|
||||
: `${genreName} - Hördle`;
|
||||
|
||||
const description = genreSubtitle || (locale === 'de'
|
||||
? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!`
|
||||
: `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`);
|
||||
|
||||
return await generateBaseMetadata(locale, genre, title, description);
|
||||
}
|
||||
|
||||
export default async function GenrePage({ params }: PageProps) {
|
||||
const { locale, genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
const tNav = await getTranslations('Navigation');
|
||||
|
||||
// Fetch all genres to find the matching one by localized name
|
||||
const allGenres = await prisma.genre.findMany();
|
||||
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(currentGenre);
|
||||
// getOrCreateDailyPuzzle likely expects string or needs update.
|
||||
// Actually, getOrCreateDailyPuzzle takes `genreName: string | null`.
|
||||
// If I pass the JSON object, it might fail.
|
||||
// But wait, the DB schema for DailyPuzzle stores `genreId`.
|
||||
// `getOrCreateDailyPuzzle` probably looks up genre by name.
|
||||
// I should check `lib/dailyPuzzle.ts`.
|
||||
// For now, I'll pass the localized name, but that might be risky if it tries to create a genre (unlikely).
|
||||
// Let's assume for now I should pass the localized name if that's what it uses to find/create.
|
||||
// But if `getOrCreateDailyPuzzle` uses `findUnique({ where: { name: genreName } })`, it will fail because name is JSON.
|
||||
// I need to update `lib/dailyPuzzle.ts` too!
|
||||
// I'll mark that as a todo. For now, let's proceed with page creation.
|
||||
|
||||
const genres = allGenres.filter(g => g.active);
|
||||
// Sort
|
||||
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||
|
||||
const specials = await prisma.special.findMany();
|
||||
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||
|
||||
const now = new Date();
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
||||
const isEnded = s.endDate && s.endDate < now;
|
||||
return isStarted && !isEnded;
|
||||
});
|
||||
|
||||
const upcomingSpecials = specials.filter(s => {
|
||||
return s.launchDate && s.launchDate > now;
|
||||
});
|
||||
|
||||
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>{tNav('global')}</Link>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => {
|
||||
const name = getLocalizedValue(g.name, locale);
|
||||
return (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${name}`}
|
||||
style={{
|
||||
fontWeight: name === decodedGenre ? 'bold' : 'normal',
|
||||
textDecoration: name === decodedGenre ? 'underline' : 'none',
|
||||
color: name === decodedGenre ? 'black' : '#4b5563'
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Specials */}
|
||||
{activeSpecials.map(s => {
|
||||
const name = getLocalizedValue(s.name, locale);
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/special/${name}`}
|
||||
style={{
|
||||
color: '#be185d', // Pink-700
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Specials */}
|
||||
{upcomingSpecials.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
||||
Coming soon: {upcomingSpecials.map(s => {
|
||||
const name = getLocalizedValue(s.name, locale);
|
||||
return (
|
||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||
★ {name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: process.env.TZ
|
||||
}) : ''})
|
||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NewsSection locale={locale} />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
324
app/[locale]/about/page.tsx
Normal file
324
app/[locale]/about/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/lib/navigation";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface AboutPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
|
||||
const title = t("title");
|
||||
const description = t("intro");
|
||||
|
||||
return await generateBaseMetadata(locale, "about", title, description);
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params }: AboutPageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
|
||||
const sheetUrl =
|
||||
"https://docs.google.com/spreadsheets/d/1LuMkDsnidlvMtzzSqwrz-GACnqMaqzs-VBa-ZK0nZeI/edit?usp=sharing";
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
maxWidth: "960px",
|
||||
margin: "0 auto",
|
||||
padding: "2rem 1rem",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "2rem", marginBottom: "1rem" }}>{t("title")}</h1>
|
||||
<p style={{ marginBottom: "2rem", color: "#4b5563" }}>{t("intro")}</p>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||
{t("projectTitle")}
|
||||
</h2>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("projectPrivateNote")}</p>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("projectIdea")}</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||
{t("imprintTitle")}
|
||||
</h2>
|
||||
<p style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>{t("imprintOperator")}</strong>
|
||||
</p>
|
||||
<p style={{ marginBottom: 0, lineHeight: "1.5" }}>
|
||||
Markus Busche
|
||||
<br />
|
||||
Knorrstr. 16
|
||||
<br />
|
||||
24106 Kiel
|
||||
<br />
|
||||
{t("imprintCountry")}
|
||||
<br />
|
||||
{t("imprintEmailLabel")}{" "}
|
||||
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||
{t("costsTitle")}
|
||||
</h2>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("costsIntro")}</p>
|
||||
<ul
|
||||
style={{
|
||||
marginLeft: "1.25rem",
|
||||
marginBottom: "0.75rem",
|
||||
listStyleType: "disc",
|
||||
}}
|
||||
>
|
||||
<li>{t("costsDomain")}</li>
|
||||
<li>{t("costsServer")}</li>
|
||||
<li>{t("costsEmail")}</li>
|
||||
<li>{t("costsLicenses")}</li>
|
||||
</ul>
|
||||
<p style={{ marginBottom: "0.5rem" }}>
|
||||
{t.rich("costsSheetLinkText", {
|
||||
link: (chunks) => (
|
||||
<a
|
||||
href={sheetUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{t("costsSheetPrivacyNote")}
|
||||
</p>
|
||||
<p style={{ marginBottom: "0.75rem" }}>
|
||||
{t.rich("costsDonationNote", {
|
||||
link: (chunks) => (
|
||||
<a
|
||||
href="https://politicalbeauty.de/ueber-das-ZPS.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||
{t("supportTitle")}
|
||||
</h2>
|
||||
<p style={{ marginBottom: "1rem" }}>{t("supportIntro")}</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportSepaTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: "0.25rem" }}>
|
||||
<strong>{t("supportSepaName")}</strong>
|
||||
</p>
|
||||
<p style={{ marginBottom: 0, fontFamily: "monospace" }}>
|
||||
{t("supportSepaIban")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportPaypalTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
<a
|
||||
href="https://paypal.me/MBusche"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{t("supportPaypalLink")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportSteadyTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: "0.5rem" }}>
|
||||
{t("supportSteadyDescription")}
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
<a
|
||||
href="https://steady.page/de/hoerdle"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
https://steady.page/de/hoerdle
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportCuratorTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{t("supportCuratorText")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportReportBugTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{t.rich("supportReportBugText", {
|
||||
email: (chunks) => (
|
||||
<a
|
||||
href="mailto:admin@hoerdle.de"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||
{t("privacyTitle")}
|
||||
</h2>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyIntro")}</p>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.25rem",
|
||||
marginTop: "1rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("privacyPlausibleTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: "0.5rem" }}>
|
||||
{t("privacyPlausibleSelfHosted")}
|
||||
</p>
|
||||
<p style={{ marginBottom: "0.5rem" }}>
|
||||
{t("privacyPlausibleGemaTariff")}
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
marginLeft: "1.25rem",
|
||||
marginBottom: "0.75rem",
|
||||
listStyleType: "disc",
|
||||
}}
|
||||
>
|
||||
<li>{t("privacyPlausibleNoCookies")}</li>
|
||||
<li>{t("privacyPlausibleNoTrackingAcrossSites")}</li>
|
||||
<li>{t("privacyPlausibleAggregated")}</li>
|
||||
</ul>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
|
||||
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||
{t("backTitle")}
|
||||
</h2>
|
||||
<p>
|
||||
<Link href="/" style={{ textDecoration: "underline" }}>
|
||||
{t("backToGame")}
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
2212
app/[locale]/admin/page.tsx
Normal file
2212
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;
|
||||
|
||||
|
||||
7
app/[locale]/curator/specials/page.tsx
Normal file
7
app/[locale]/curator/specials/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import CuratorSpecialsPage from '@/app/curator/specials/page';
|
||||
|
||||
export default CuratorSpecialsPage;
|
||||
|
||||
|
||||
98
app/[locale]/layout.tsx
Normal file
98
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import "../globals.css"; // Adjusted path
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { config } from "@/lib/config";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return await generateBaseMetadata(locale);
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: config.colors.themeColor,
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
console.log('[app/[locale]/layout] params locale:', locale);
|
||||
|
||||
// Ensure that the incoming `locale` is valid
|
||||
if (!['en', 'de'].includes(locale)) {
|
||||
console.log('[app/[locale]/layout] invalid locale, triggering notFound()');
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Providing all messages to the client
|
||||
const messages = await getMessages();
|
||||
|
||||
// Get current domain from request headers for dynamic Plausible tracking
|
||||
// This automatically tracks the correct domain (hoerdle.de or hördle.de)
|
||||
const headersList = await headers();
|
||||
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||
|
||||
// Automatically detect which domain to track in Plausible based on the request
|
||||
let plausibleDomain = 'hoerdle.de'; // Default fallback
|
||||
|
||||
if (host) {
|
||||
// Extract domain from host (remove port if present)
|
||||
const domain = host.split(':')[0].toLowerCase();
|
||||
|
||||
// Map domains: automatically track the current domain
|
||||
if (domain === 'hoerdle.de') {
|
||||
plausibleDomain = 'hoerdle.de';
|
||||
} else if (domain === 'hördle.de' || domain === 'xn--hrdle-jua.de') {
|
||||
plausibleDomain = 'hördle.de';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
data-domain={plausibleDomain}
|
||||
src={config.plausibleScriptSrc}
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<AppFooter />
|
||||
<PoliticalStatementBanner />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
159
app/[locale]/page.tsx
Normal file
159
app/[locale]/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import Game from '@/components/Game';
|
||||
import NewsSection from '@/components/NewsSection';
|
||||
import OnboardingTour from '@/components/OnboardingTour';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
|
||||
// Get localized title and description
|
||||
const title = locale === 'de'
|
||||
? 'Hördle - Tägliches Musik-Erraten'
|
||||
: 'Hördle - Daily Music Guessing Game';
|
||||
|
||||
const description = locale === 'de'
|
||||
? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.'
|
||||
: 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.';
|
||||
|
||||
return await generateBaseMetadata(locale, '', title, description);
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
const tNav = await getTranslations('Navigation');
|
||||
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
});
|
||||
const specials = await prisma.special.findMany();
|
||||
|
||||
// Sort in memory
|
||||
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
||||
const isEnded = s.endDate && s.endDate < now;
|
||||
return isStarted && !isEnded;
|
||||
});
|
||||
|
||||
const upcomingSpecials = specials.filter(s => {
|
||||
return s.launchDate && s.launchDate > now;
|
||||
});
|
||||
|
||||
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||
{/* Language Switcher - rechts oben */}
|
||||
<div style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
{/* Zentrierte Navigation */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<div className="tooltip">
|
||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>{tNav('global')}</Link>
|
||||
<span className="tooltip-text">{t('globalTooltip')}</span>
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => {
|
||||
const name = getLocalizedValue(g.name, locale);
|
||||
const subtitle = getLocalizedValue(g.subtitle, locale);
|
||||
return (
|
||||
<div key={g.id} className="tooltip">
|
||||
<Link href={`/${name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||
{name}
|
||||
</Link>
|
||||
{subtitle && <span className="tooltip-text">{subtitle}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Active Specials */}
|
||||
{activeSpecials.map(s => {
|
||||
const name = getLocalizedValue(s.name, locale);
|
||||
const subtitle = getLocalizedValue(s.subtitle, locale);
|
||||
return (
|
||||
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div className="tooltip">
|
||||
<Link
|
||||
href={`/special/${name}`}
|
||||
style={{
|
||||
color: '#be185d', // Pink-700
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {name}
|
||||
</Link>
|
||||
{subtitle && <span className="tooltip-text">{subtitle}</span>}
|
||||
</div>
|
||||
{s.curator && (
|
||||
<span style={{ fontSize: '0.75rem', color: '#666' }}>
|
||||
{t('curatedBy')} {s.curator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Specials */}
|
||||
{upcomingSpecials.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666', textAlign: 'center' }}>
|
||||
{t('comingSoon')}: {upcomingSpecials.map(s => {
|
||||
const name = getLocalizedValue(s.name, locale);
|
||||
return (
|
||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||
★ {name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: process.env.TZ
|
||||
}) : ''})
|
||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>{t('curatedBy')} {s.curator}</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="tour-news">
|
||||
<NewsSection locale={locale} />
|
||||
</div>
|
||||
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
|
||||
<OnboardingTour />
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
app/[locale]/special/[name]/page.tsx
Normal file
151
app/[locale]/special/[name]/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import Game from '@/components/Game';
|
||||
import NewsSection from '@/components/NewsSection';
|
||||
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string; name: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
// Fetch special to get localized name
|
||||
const allSpecials = await prisma.special.findMany();
|
||||
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
|
||||
|
||||
if (!currentSpecial) {
|
||||
return await generateBaseMetadata(locale, `special/${name}`);
|
||||
}
|
||||
|
||||
const specialName = getLocalizedValue(currentSpecial.name, locale);
|
||||
const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale);
|
||||
|
||||
const title = `★ ${specialName} - Hördle`;
|
||||
|
||||
const description = specialSubtitle || (locale === 'de'
|
||||
? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!`
|
||||
: `Play the Hördle special "${specialName}" and guess songs from short audio clips!`);
|
||||
|
||||
return await generateBaseMetadata(locale, `special/${name}`, title, description);
|
||||
}
|
||||
|
||||
export default async function SpecialPage({ params }: PageProps) {
|
||||
const { locale, name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
const tNav = await getTranslations('Navigation');
|
||||
|
||||
const allSpecials = await prisma.special.findMany();
|
||||
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
|
||||
|
||||
const now = new Date();
|
||||
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
|
||||
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
|
||||
|
||||
if (!currentSpecial || !isStarted) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h1>Special Not Available</h1>
|
||||
<p>This special has not launched yet or does not exist.</p>
|
||||
<Link href="/">{tNav('home')}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEnded) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h1>Special Ended</h1>
|
||||
<p>This special event has ended.</p>
|
||||
<Link href="/">{tNav('home')}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Need to handle getOrCreateSpecialPuzzle with localized name or ID
|
||||
// Ideally pass ID or full object, but existing function takes name string.
|
||||
// I'll need to update lib/dailyPuzzle.ts to handle this.
|
||||
const dailyPuzzle = await getOrCreateSpecialPuzzle(currentSpecial);
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
});
|
||||
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||
|
||||
const specials = allSpecials; // Already fetched
|
||||
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const sStarted = !s.launchDate || s.launchDate <= now;
|
||||
const sEnded = s.endDate && s.endDate < now;
|
||||
return sStarted && !sEnded;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>{tNav('global')}</Link>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => {
|
||||
const gName = getLocalizedValue(g.name, locale);
|
||||
return (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${gName}`}
|
||||
style={{
|
||||
color: '#4b5563',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
{gName}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Specials */}
|
||||
{activeSpecials.map(s => {
|
||||
const sName = getLocalizedValue(s.name, locale);
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/special/${sName}`}
|
||||
style={{
|
||||
fontWeight: sName === decodedName ? 'bold' : 'normal',
|
||||
textDecoration: sName === decodedName ? 'underline' : 'none',
|
||||
color: sName === decodedName ? '#9d174d' : '#be185d'
|
||||
}}
|
||||
>
|
||||
★ {sName}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<NewsSection locale={locale} />
|
||||
<Game
|
||||
dailyPuzzle={dailyPuzzle}
|
||||
genre={decodedName}
|
||||
isSpecial={true}
|
||||
maxAttempts={dailyPuzzle?.maxAttempts}
|
||||
unlockSteps={dailyPuzzle?.unlockSteps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -80,3 +80,30 @@ export async function submitRating(songId: number, rating: number, genre?: strin
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
1300
app/admin/page.tsx
1300
app/admin/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,102 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import WaveformEditor from '@/components/WaveformEditor';
|
||||
|
||||
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[];
|
||||
}
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||
|
||||
export default function SpecialEditorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const specialId = params.id as string;
|
||||
|
||||
const [special, setSpecial] = useState<Special | null>(null);
|
||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(null);
|
||||
// Locale aus dem Pfad ableiten (/en/..., /de/...)
|
||||
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 [saving, setSaving] = useState(false);
|
||||
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSpecial = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/specials/${specialId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSpecial(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching special:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSpecial();
|
||||
}, [specialId]);
|
||||
|
||||
const fetchSpecial = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/specials/${specialId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
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) {
|
||||
console.error('Error fetching special:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songId, startTime }),
|
||||
});
|
||||
|
||||
const handleStartTimeChange = (newStartTime: number) => {
|
||||
setPendingStartTime(newStartTime);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!special || !selectedSongId || pendingStartTime === null) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime })
|
||||
});
|
||||
|
||||
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);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => res.statusText || 'Unknown error');
|
||||
console.error('Error updating special song (admin):', res.status, errorText);
|
||||
throw new Error(`Failed to save start time: ${errorText}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,116 +66,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 (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<button
|
||||
onClick={() => router.push('/admin')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
border: 'none',
|
||||
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>
|
||||
<CurateSpecialEditor
|
||||
special={special}
|
||||
locale={locale}
|
||||
onBack={() => router.push('/admin')}
|
||||
onSaveStartTime={handleSaveStartTime}
|
||||
backLabel="← Back to Admin"
|
||||
headerPrefix="Edit Special:"
|
||||
noSongsSubHint="Go back to the admin dashboard to add songs to this special."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,14 +98,14 @@ export async function DELETE(request: Request) {
|
||||
where: { id: puzzle.specialId }
|
||||
});
|
||||
if (special) {
|
||||
newPuzzle = await getOrCreateSpecialPuzzle(special.name);
|
||||
newPuzzle = await getOrCreateSpecialPuzzle(special);
|
||||
}
|
||||
} else if (puzzle.genreId) {
|
||||
const genre = await prisma.genre.findUnique({
|
||||
where: { id: puzzle.genreId }
|
||||
});
|
||||
if (genre) {
|
||||
newPuzzle = await getOrCreateDailyPuzzle(genre.name);
|
||||
newPuzzle = await getOrCreateDailyPuzzle(genre);
|
||||
}
|
||||
} else {
|
||||
newPuzzle = await getOrCreateDailyPuzzle(null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
import { stat } from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function GET(
|
||||
@@ -30,24 +31,106 @@ export async function GET(
|
||||
return new NextResponse('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await stat(filePath);
|
||||
} catch {
|
||||
return new NextResponse('File not found', { status: 404 });
|
||||
const stats = await stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
const range = request.headers.get('range');
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
|
||||
const stream = createReadStream(filePath, { start, end });
|
||||
|
||||
// Convert Node stream to Web stream
|
||||
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
let isClosed = false;
|
||||
|
||||
stream.on('data', (chunk: any) => {
|
||||
if (isClosed) return;
|
||||
try {
|
||||
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) {
|
||||
console.error('Error serving audio file:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -83,7 +84,8 @@ export async function POST(request: Request) {
|
||||
// Process each song in this batch
|
||||
for (const song of uncategorizedSongs) {
|
||||
try {
|
||||
const genreNames = allGenres.map(g => g.name);
|
||||
// Use German names for AI categorization (primary language)
|
||||
const genreNames = allGenres.map(g => getLocalizedValue(g.name, 'de'));
|
||||
|
||||
const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres.
|
||||
|
||||
@@ -140,7 +142,7 @@ Your response:`;
|
||||
|
||||
// Filter to only valid genres and get their IDs
|
||||
const genreIds = allGenres
|
||||
.filter(g => suggestedGenreNames.includes(g.name))
|
||||
.filter(g => suggestedGenreNames.includes(getLocalizedValue(g.name, 'de')))
|
||||
.map(g => g.id)
|
||||
.slice(0, 3); // Max 3 genres
|
||||
|
||||
@@ -160,7 +162,7 @@ Your response:`;
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
assignedGenres: suggestedGenreNames.filter(name =>
|
||||
allGenres.some(g => g.name === name)
|
||||
allGenres.some(g => getLocalizedValue(g.name, 'de') === name)
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
58
app/api/curator/specials/[id]/route.ts
Normal file
58
app/api/curator/specials/[id]/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
{ 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 });
|
||||
}
|
||||
|
||||
return NextResponse.json(special);
|
||||
}
|
||||
|
||||
|
||||
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 { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const genreName = searchParams.get('genre');
|
||||
|
||||
const puzzle = await getOrCreateDailyPuzzle(genreName);
|
||||
let genre = null;
|
||||
if (genreName) {
|
||||
// Find genre by localized name (try both locales)
|
||||
const allGenres = await prisma.genre.findMany();
|
||||
genre = allGenres.find(g =>
|
||||
getLocalizedValue(g.name, 'de') === genreName ||
|
||||
getLocalizedValue(g.name, 'en') === genreName
|
||||
) || null;
|
||||
}
|
||||
|
||||
const puzzle = await getOrCreateDailyPuzzle(genre);
|
||||
|
||||
if (!puzzle) {
|
||||
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale');
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
// orderBy: { name: 'asc' }, // Cannot sort by JSON field easily in SQLite
|
||||
include: {
|
||||
_count: {
|
||||
select: { songs: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort in memory if needed, or just return
|
||||
// If locale is provided, map to localized values
|
||||
if (locale) {
|
||||
const localizedGenres = genres.map(g => ({
|
||||
...g,
|
||||
name: getLocalizedValue(g.name, locale),
|
||||
subtitle: getLocalizedValue(g.subtitle, locale)
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
return NextResponse.json(localizedGenres);
|
||||
}
|
||||
|
||||
return NextResponse.json(genres);
|
||||
} catch (error) {
|
||||
console.error('Error fetching genres:', error);
|
||||
@@ -29,14 +45,18 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const { name, subtitle, active } = await request.json();
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure name is stored as JSON
|
||||
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
|
||||
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||
|
||||
const genre = await prisma.genre.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
name: nameData,
|
||||
subtitle: subtitleData,
|
||||
active: active !== undefined ? active : true
|
||||
},
|
||||
});
|
||||
@@ -83,13 +103,14 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
|
||||
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||
if (active !== undefined) updateData.active = active;
|
||||
|
||||
const genre = await prisma.genre.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(name && { name: name.trim() }),
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
...(active !== undefined && { active })
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json(genre);
|
||||
|
||||
5
app/api/health/route.ts
Normal file
5
app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok' }, { status: 200 });
|
||||
}
|
||||
165
app/api/news/route.ts
Normal file
165
app/api/news/route.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET /api/news - Public endpoint to fetch news
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const featuredOnly = searchParams.get('featured') === 'true';
|
||||
const locale = searchParams.get('locale');
|
||||
|
||||
const where = featuredOnly ? { featured: true } : {};
|
||||
|
||||
const news = await prisma.news.findMany({
|
||||
where,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (locale) {
|
||||
const localizedNews = news.map(item => ({
|
||||
...item,
|
||||
title: getLocalizedValue(item.title, locale),
|
||||
content: getLocalizedValue(item.content, locale),
|
||||
special: item.special ? {
|
||||
...item.special,
|
||||
name: getLocalizedValue(item.special.name, locale)
|
||||
} : null
|
||||
}));
|
||||
return NextResponse.json(localizedNews);
|
||||
}
|
||||
|
||||
return NextResponse.json(news);
|
||||
} catch (error) {
|
||||
console.error('Error fetching news:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/news - Create news (requires auth)
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { title, content, author, featured, specialId } = body;
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title and content are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure title and content are stored as JSON
|
||||
const titleData = typeof title === 'string' ? { de: title, en: title } : title;
|
||||
const contentData = typeof content === 'string' ? { de: content, en: content } : content;
|
||||
|
||||
const news = await prisma.news.create({
|
||||
data: {
|
||||
title: titleData,
|
||||
content: contentData,
|
||||
author: author || null,
|
||||
featured: featured || false,
|
||||
specialId: specialId || null
|
||||
},
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(news, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating news:', error);
|
||||
return NextResponse.json({ error: 'Failed to create news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/news - Update news (requires auth)
|
||||
export async function PUT(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, title, content, author, featured, specialId } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (title !== undefined) updateData.title = typeof title === 'string' ? { de: title, en: title } : title;
|
||||
if (content !== undefined) updateData.content = typeof content === 'string' ? { de: content, en: content } : content;
|
||||
if (author !== undefined) updateData.author = author || null;
|
||||
if (featured !== undefined) updateData.featured = featured;
|
||||
if (specialId !== undefined) updateData.specialId = specialId || null;
|
||||
|
||||
const news = await prisma.news.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(news);
|
||||
} catch (error) {
|
||||
console.error('Error updating news:', error);
|
||||
return NextResponse.json({ error: 'Failed to update news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/news - Delete news (requires auth)
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.news.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting news:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
78
app/api/og-image/route.ts
Normal file
78
app/api/og-image/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { config } from '@/lib/config';
|
||||
import { getBaseUrl } from '@/lib/seo';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Generate Open Graph image as SVG with correct aspect ratio (1.91:1 = 1200x630)
|
||||
* This prevents cropping on Facebook and Twitter
|
||||
*/
|
||||
export async function GET() {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const appName = config.appName;
|
||||
const bgColor = config.colors.backgroundColor || '#ffffff';
|
||||
const primaryColor = config.colors.themeColor || '#000000';
|
||||
|
||||
// SVG with correct Open Graph dimensions: 1200x630 (1.91:1 ratio)
|
||||
// Safe area: 150px padding on all sides to prevent cropping
|
||||
// This ensures content is never cut off on Facebook/Twitter
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="${bgColor}"/>
|
||||
|
||||
<!-- Gradient definition -->
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Content container - centered with safe padding (150px on all sides) -->
|
||||
<g transform="translate(150, 150)">
|
||||
<!-- Main graphic area (centered horizontally) -->
|
||||
<g transform="translate(300, 0)">
|
||||
<!-- Musical note (left side, within safe area) -->
|
||||
<g fill="url(#gradient)" opacity="0.9">
|
||||
<!-- Note head -->
|
||||
<ellipse cx="0" cy="40" rx="40" ry="28"/>
|
||||
<!-- Note stem -->
|
||||
<rect x="30" y="-60" width="16" height="100" rx="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Waveform (center-right, within safe area) -->
|
||||
<g transform="translate(70, 15)" fill="none" stroke="url(#gradient)" stroke-width="8" stroke-linecap="round" opacity="0.8">
|
||||
<path d="M 0 25 Q 20 -15 40 25 T 80 25"/>
|
||||
<path d="M 0 40 Q 20 0 40 40 T 80 40"/>
|
||||
<path d="M 0 55 Q 20 15 40 55 T 80 55"/>
|
||||
</g>
|
||||
|
||||
<!-- Vertical bar (right side, within safe area) -->
|
||||
<rect x="170" y="0" width="10" height="120" fill="url(#gradient)" opacity="0.7" rx="5"/>
|
||||
</g>
|
||||
|
||||
<!-- App name (centered, within safe vertical area) -->
|
||||
<text x="450" y="180" font-family="system-ui, -apple-system, sans-serif" font-size="56" font-weight="bold" fill="${primaryColor}" text-anchor="middle" letter-spacing="-0.5">
|
||||
${appName}
|
||||
</text>
|
||||
|
||||
<!-- Domain/subtitle (centered, within safe vertical area) -->
|
||||
<text x="450" y="220" font-family="system-ui, -apple-system, sans-serif" font-size="28" fill="#666666" text-anchor="middle">
|
||||
${config.domain}
|
||||
</text>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
return new NextResponse(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
114
app/api/player-id/suggest/route.ts
Normal file
114
app/api/player-id/suggest/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/player-id/suggest
|
||||
*
|
||||
* Tries to find a base player ID based on recently updated states for a genre and device.
|
||||
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de)
|
||||
* on the same device.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - deviceId: Device identifier (UUID)
|
||||
*
|
||||
* Returns:
|
||||
* - basePlayerId: Suggested base player ID (UUID) if found, null otherwise
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { genreKey, deviceId } = body;
|
||||
|
||||
if (!genreKey || typeof genreKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing or invalid genreKey' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the most recently updated player state for this genre
|
||||
// Look for states updated in the last 48 hours
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setHours(cutoffDate.getHours() - 48);
|
||||
|
||||
// If deviceId is provided, search for states with matching device ID
|
||||
// Format: {basePlayerId}:{deviceId}
|
||||
if (deviceId && typeof deviceId === 'string') {
|
||||
// Search for states with the same device ID
|
||||
const recentStates = await prisma.playerState.findMany({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
lastPlayed: {
|
||||
gte: cutoffDate,
|
||||
},
|
||||
identifier: {
|
||||
endsWith: `:${deviceId}`,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastPlayed: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (recentStates.length > 0) {
|
||||
const recentState = recentStates[0];
|
||||
// Extract base player ID from full identifier
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||
return NextResponse.json({
|
||||
basePlayerId: basePlayerId,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Find any recent state for this genre (legacy support)
|
||||
const recentState = await prisma.playerState.findFirst({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
lastPlayed: {
|
||||
gte: cutoffDate,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastPlayed: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (recentState) {
|
||||
// Extract base player ID if format is basePlayerId:deviceId
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||
return NextResponse.json({
|
||||
basePlayerId: basePlayerId,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
} else {
|
||||
// Legacy format: return as-is
|
||||
return NextResponse.json({
|
||||
basePlayerId: recentState.identifier,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No recent state found
|
||||
return NextResponse.json({
|
||||
basePlayerId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-id/suggest] Error finding player ID:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
192
app/api/player-state/route.ts
Normal file
192
app/api/player-state/route.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import type { GameState, Statistics } from '@/lib/gameState';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Validate UUID format (basic check)
|
||||
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
|
||||
*/
|
||||
function isValidPlayerId(playerId: string): boolean {
|
||||
// Legacy format: single UUID
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// New format: basePlayerId:deviceId (two UUIDs separated by colon)
|
||||
const combinedRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
return uuidRegex.test(playerId) || combinedRegex.test(playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base player ID from full player ID
|
||||
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||
* Legacy: {uuid} -> {uuid}
|
||||
*/
|
||||
function extractBasePlayerId(fullPlayerId: string): string {
|
||||
const colonIndex = fullPlayerId.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
// Legacy format (no device ID) - return as is
|
||||
return fullPlayerId;
|
||||
}
|
||||
return fullPlayerId.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/player-state
|
||||
*
|
||||
* Loads player state for a given identifier and genre/special.
|
||||
*
|
||||
* Query parameters:
|
||||
* - genre: Genre name (e.g., "Rock")
|
||||
* - special: Special name (e.g., "00725")
|
||||
*
|
||||
* Headers:
|
||||
* - X-Player-Id: Player identifier (UUID)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const genreName = searchParams.get('genre');
|
||||
const specialName = searchParams.get('special');
|
||||
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine genre key
|
||||
let genreKey: string;
|
||||
if (specialName) {
|
||||
genreKey = `special:${specialName}`;
|
||||
} else if (genreName) {
|
||||
genreKey = genreName;
|
||||
} else {
|
||||
genreKey = 'global';
|
||||
}
|
||||
|
||||
// Load player state from database
|
||||
const playerState = await prisma.playerState.findUnique({
|
||||
where: {
|
||||
identifier_genreKey: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!playerState) {
|
||||
return NextResponse.json(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse JSON strings
|
||||
let gameState: GameState;
|
||||
let statistics: Statistics;
|
||||
|
||||
try {
|
||||
gameState = JSON.parse(playerState.gameState) as GameState;
|
||||
statistics = JSON.parse(playerState.statistics) as Statistics;
|
||||
} catch (parseError) {
|
||||
console.error('[player-state] Failed to parse stored state:', parseError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid stored state format' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
gameState,
|
||||
statistics,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-state] Error loading player state:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/player-state
|
||||
*
|
||||
* Saves player state for a given identifier and genre/special.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - gameState: GameState object
|
||||
* - statistics: Statistics object
|
||||
*
|
||||
* Headers:
|
||||
* - X-Player-Id: Player identifier (UUID)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { genreKey, gameState, statistics } = body;
|
||||
|
||||
if (!genreKey || !gameState || !statistics) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: genreKey, gameState, statistics' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate genre key format
|
||||
if (typeof genreKey !== 'string' || genreKey.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid genreKey format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Serialize to JSON strings
|
||||
const gameStateJson = JSON.stringify(gameState);
|
||||
const statisticsJson = JSON.stringify(statistics);
|
||||
|
||||
// Upsert player state (update if exists, create if not)
|
||||
await prisma.playerState.upsert({
|
||||
where: {
|
||||
identifier_genreKey: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
gameState: gameStateJson,
|
||||
statistics: statisticsJson,
|
||||
lastPlayed: new Date(),
|
||||
},
|
||||
create: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
gameState: gameStateJson,
|
||||
statistics: statisticsJson,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[player-state] Error saving player state:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
113
app/api/political-statements/route.ts
Normal file
113
app/api/political-statements/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import {
|
||||
getRandomActiveStatement,
|
||||
getAllStatements,
|
||||
createStatement,
|
||||
updateStatement,
|
||||
deleteStatement,
|
||||
} from '@/lib/politicalStatements';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
const admin = searchParams.get('admin') === 'true';
|
||||
|
||||
if (admin) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
const statements = await getAllStatements(locale);
|
||||
return NextResponse.json(statements);
|
||||
}
|
||||
|
||||
const statement = await getRandomActiveStatement(locale);
|
||||
return NextResponse.json(statement);
|
||||
} catch (error) {
|
||||
console.error('[political-statements] GET failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to load political statements' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, text, active = true, source } = body;
|
||||
|
||||
if (!locale || typeof text !== 'string' || !text.trim()) {
|
||||
return NextResponse.json({ error: 'locale and text are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await createStatement(locale, { text: text.trim(), active, source });
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('[political-statements] POST failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to create statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, id, text, active, source } = body;
|
||||
|
||||
if (!locale || typeof id !== 'number') {
|
||||
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await updateStatement(locale, id, {
|
||||
text: typeof text === 'string' ? text.trim() : undefined,
|
||||
active,
|
||||
source,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('[political-statements] PUT failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to update statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, id } = body;
|
||||
|
||||
if (!locale || typeof id !== 'number') {
|
||||
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = await deleteStatement(locale, id);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[political-statements] DELETE failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
app/api/public-songs/route.ts
Normal file
21
app/api/public-songs/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Öffentliche, schreibgeschützte Song-Liste für das Spiel (GuessInput etc.).
|
||||
// Kein Auth, nur Lesen der nötigsten Felder.
|
||||
export async function GET() {
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
artist: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(songs);
|
||||
}
|
||||
|
||||
|
||||
92
app/api/rewrite-message/route.ts
Normal file
92
app/api/rewrite-message/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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();
|
||||
|
||||
// Compare with original message (case-insensitive and ignoring extra whitespace)
|
||||
const originalTrimmed = message.trim();
|
||||
const rewrittenTrimmed = rewrittenMessage.trim();
|
||||
|
||||
// Check if message was actually changed (normalize for comparison)
|
||||
const wasChanged = rewrittenTrimmed.toLowerCase() !== originalTrimmed.toLowerCase() &&
|
||||
rewrittenTrimmed !== originalTrimmed;
|
||||
|
||||
if (wasChanged) {
|
||||
rewrittenMessage = rewrittenTrimmed + " (autocorrected by Polite-Bot)";
|
||||
} else {
|
||||
// Return original message if not changed
|
||||
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 { writeFile, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { parseBuffer } from 'music-metadata';
|
||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function getCuratorAssignments(curatorId: number) {
|
||||
const [genres, specials] = await Promise.all([
|
||||
prisma.curatorGenre.findMany({
|
||||
where: { curatorId },
|
||||
select: { genreId: true },
|
||||
}),
|
||||
prisma.curatorSpecial.findMany({
|
||||
where: { curatorId },
|
||||
select: { specialId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
genreIds: new Set(genres.map(g => g.genreId)),
|
||||
specialIds: new Set(specials.map(s => s.specialId)),
|
||||
};
|
||||
}
|
||||
|
||||
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||
// `song.specials` kann je nach Context entweder ein Array von
|
||||
// - `Special` (mit `id`)
|
||||
// - `SpecialSong` (mit `specialId`)
|
||||
// - `SpecialSong` (mit Relation `special.id`)
|
||||
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
||||
// 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
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) return error!;
|
||||
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
@@ -26,8 +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
|
||||
const songsWithActivations = songs.map(song => ({
|
||||
const songsWithActivations = visibleSongs.map(song => ({
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
@@ -38,7 +141,10 @@ export async function GET() {
|
||||
activations: song.puzzles.length,
|
||||
puzzles: song.puzzles,
|
||||
genres: song.genres,
|
||||
specials: song.specials.map(ss => ss.special),
|
||||
// Nur Specials mit existierender Relation durchreichen, um undefinierte Einträge zu vermeiden.
|
||||
specials: song.specials
|
||||
.map(ss => ss.special)
|
||||
.filter((s): s is any => !!s),
|
||||
averageRating: song.averageRating,
|
||||
ratingCount: song.ratingCount,
|
||||
excludeFromGlobal: song.excludeFromGlobal,
|
||||
@@ -50,11 +156,11 @@ export async function GET() {
|
||||
export async function POST(request: Request) {
|
||||
console.log('[UPLOAD] Starting song upload request');
|
||||
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) {
|
||||
console.log('[UPLOAD] Authentication failed');
|
||||
return authError;
|
||||
return error!;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -63,10 +169,17 @@ export async function POST(request: Request) {
|
||||
const file = formData.get('file') as File;
|
||||
let title = '';
|
||||
let artist = '';
|
||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
|
||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
||||
console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
|
||||
|
||||
// Apply global playlist rules:
|
||||
// - Admin: may control the flag via form data
|
||||
// - Curator: uploads are always excluded from global by default
|
||||
if (context.role === 'curator') {
|
||||
excludeFromGlobal = true;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
console.error('[UPLOAD] No file provided');
|
||||
@@ -101,6 +214,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// Validate and extract metadata from file
|
||||
let metadata;
|
||||
let releaseYear: number | null = null;
|
||||
let validationInfo = {
|
||||
isValid: true,
|
||||
hasCover: false,
|
||||
@@ -131,6 +245,11 @@ export async function POST(request: Request) {
|
||||
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
|
||||
validationInfo.hasCover = !!metadata.common.picture?.[0];
|
||||
validationInfo.format = metadata.format.container || 'unknown';
|
||||
@@ -225,17 +344,19 @@ export async function POST(request: Request) {
|
||||
console.error('Failed to extract cover image:', e);
|
||||
}
|
||||
|
||||
// Fetch release year from iTunes
|
||||
let releaseYear = null;
|
||||
try {
|
||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||
releaseYear = await getReleaseYearFromItunes(artist, title);
|
||||
// Fetch release year from iTunes only if not already present from tags
|
||||
if (releaseYear == null) {
|
||||
try {
|
||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||
const fetchedYear = await getReleaseYearFromItunes(artist, title);
|
||||
|
||||
if (releaseYear) {
|
||||
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
||||
if (fetchedYear) {
|
||||
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({
|
||||
@@ -261,9 +382,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||
@@ -272,6 +393,73 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Load current song with relations for permission checks
|
||||
const existingSong = await prisma.song.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
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 };
|
||||
|
||||
// Update releaseYear if provided (can be null to clear it)
|
||||
@@ -280,60 +468,76 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
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 = {
|
||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||
};
|
||||
}
|
||||
|
||||
// Handle SpecialSong relations separately
|
||||
if (specialIds !== undefined) {
|
||||
// First, get current special assignments
|
||||
const currentSpecials = await prisma.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const newSpecialIds = specialIds as number[];
|
||||
|
||||
// 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 }
|
||||
}
|
||||
// Execute all database write operations in a transaction to ensure consistency
|
||||
const updatedSong = await prisma.$transaction(async (tx) => {
|
||||
// Handle SpecialSong relations separately
|
||||
if (effectiveSpecialIds !== undefined) {
|
||||
// First, get current special assignments (within transaction)
|
||||
const currentSpecials = await tx.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
}
|
||||
|
||||
// Add new specials
|
||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||
if (toAdd.length > 0) {
|
||||
await prisma.specialSong.createMany({
|
||||
data: toAdd.map(specialId => ({
|
||||
songId: Number(id),
|
||||
specialId,
|
||||
startTime: 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const newSpecialIds = effectiveSpecialIds as number[];
|
||||
|
||||
const updatedSong = await prisma.song.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||
if (toDelete.length > 0) {
|
||||
await tx.specialSong.deleteMany({
|
||||
where: {
|
||||
songId: Number(id),
|
||||
specialId: { in: toDelete }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -344,9 +548,9 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { id } = await request.json();
|
||||
@@ -355,16 +559,31 @@ export async function DELETE(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get song to find filename
|
||||
// Get song to find filename and relations for permission checks
|
||||
const song = await prisma.song.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!song) {
|
||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 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);
|
||||
try {
|
||||
await unlink(filePath);
|
||||
@@ -383,9 +602,11 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database (will cascade delete related puzzles)
|
||||
await prisma.song.delete({
|
||||
where: { id: Number(id) },
|
||||
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.song.delete({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { PrismaClient, Special } from '@prisma/client';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale');
|
||||
|
||||
const specials = await prisma.special.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
// orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { songs: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (locale) {
|
||||
const localizedSpecials = specials.map(s => ({
|
||||
...s,
|
||||
name: getLocalizedValue(s.name, locale),
|
||||
subtitle: getLocalizedValue(s.subtitle, locale)
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
return NextResponse.json(localizedSpecials);
|
||||
}
|
||||
|
||||
return NextResponse.json(specials);
|
||||
}
|
||||
|
||||
@@ -25,10 +39,15 @@ export async function POST(request: Request) {
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure name is stored as JSON
|
||||
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
|
||||
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||
|
||||
const special = await prisma.special.create({
|
||||
data: {
|
||||
name,
|
||||
subtitle: subtitle || null,
|
||||
name: nameData,
|
||||
subtitle: subtitleData,
|
||||
maxAttempts: Number(maxAttempts),
|
||||
unlockSteps,
|
||||
launchDate: launchDate ? new Date(launchDate) : null,
|
||||
@@ -61,17 +80,19 @@ export async function PUT(request: Request) {
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
|
||||
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||
if (maxAttempts) updateData.maxAttempts = Number(maxAttempts);
|
||||
if (unlockSteps) updateData.unlockSteps = unlockSteps;
|
||||
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
|
||||
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
|
||||
if (curator !== undefined) updateData.curator = curator || null;
|
||||
|
||||
const updated = await prisma.special.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
subtitle: subtitle || null, // Allow clearing or setting
|
||||
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
|
||||
...(unlockSteps && { unlockSteps }),
|
||||
launchDate: launchDate ? new Date(launchDate) : null,
|
||||
endDate: endDate ? new Date(endDate) : null,
|
||||
curator: curator || null,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Try to get the git tag/version
|
||||
// First check if version file exists (Docker deployment)
|
||||
// Try both /app/version.txt (Docker) and ./version.txt (local)
|
||||
const versionPaths = [
|
||||
'/app/version.txt',
|
||||
join(process.cwd(), 'version.txt')
|
||||
];
|
||||
|
||||
for (const versionFilePath of versionPaths) {
|
||||
if (existsSync(versionFilePath)) {
|
||||
const version = readFileSync(versionFilePath, 'utf-8').trim();
|
||||
if (version && version !== 'unknown' && version !== '') {
|
||||
return NextResponse.json({ version });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check environment variable
|
||||
if (process.env.APP_VERSION) {
|
||||
return NextResponse.json({ version: process.env.APP_VERSION });
|
||||
}
|
||||
|
||||
// Fallback: check package.json
|
||||
try {
|
||||
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (packageJson.version) {
|
||||
return NextResponse.json({ version: `v${packageJson.version}` });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore package.json read errors
|
||||
}
|
||||
|
||||
// Fallback: try to get from git (local development)
|
||||
let version = 'dev';
|
||||
|
||||
try {
|
||||
|
||||
2119
app/curator/CuratorPageClient.tsx
Normal file
2119
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 />;
|
||||
}
|
||||
|
||||
|
||||
170
app/curator/specials/[id]/page.tsx
Normal file
170
app/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'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);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSpecial = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/curator/specials/${specialId}`, {
|
||||
headers: getCuratorAuthHeaders(),
|
||||
});
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (specialId) {
|
||||
fetchSpecial();
|
||||
}
|
||||
}, [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');
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
|
||||
/* Theme Colors */
|
||||
--primary: #000000;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #4b5563;
|
||||
--secondary-foreground: #ffffff;
|
||||
--accent: #667eea;
|
||||
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--success: #22c55e;
|
||||
--success-foreground: #ffffff;
|
||||
--danger: #ef4444;
|
||||
--danger-foreground: #ffffff;
|
||||
--warning: #ffc107;
|
||||
--muted: #f3f4f6;
|
||||
--muted-foreground: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--input: #d1d5db;
|
||||
--ring: #000000;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -51,13 +69,13 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Audio Player */
|
||||
.audio-player {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
@@ -73,8 +91,8 @@ body {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -85,19 +103,20 @@ body {
|
||||
|
||||
.play-button:hover {
|
||||
background: #333;
|
||||
/* Keep for now or add --primary-hover */
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
flex: 1;
|
||||
height: 0.5rem;
|
||||
background: #d1d5db;
|
||||
background: var(--input);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #22c55e;
|
||||
background: var(--success);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
@@ -114,7 +133,7 @@ body {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -125,7 +144,7 @@ body {
|
||||
}
|
||||
|
||||
.guess-text {
|
||||
color: #ef4444;
|
||||
color: var(--danger);
|
||||
/* Red for wrong */
|
||||
}
|
||||
|
||||
@@ -135,7 +154,7 @@ body {
|
||||
}
|
||||
|
||||
.guess-text.correct {
|
||||
color: #22c55e;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Input */
|
||||
@@ -148,14 +167,14 @@ body {
|
||||
.guess-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.guess-input:focus {
|
||||
outline: 2px solid #000;
|
||||
outline: 2px solid var(--ring);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@@ -163,7 +182,7 @@ body {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
max-height: 15rem;
|
||||
@@ -177,11 +196,11 @@ body {
|
||||
.suggestion-item {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
border-bottom: 1px solid var(--muted);
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
@@ -190,14 +209,14 @@ body {
|
||||
|
||||
.suggestion-artist {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.skip-button {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-top: 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -246,7 +265,7 @@ body {
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@@ -265,14 +284,14 @@ body {
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -292,8 +311,8 @@ body {
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #4b5563;
|
||||
color: #fff;
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -312,8 +331,8 @@ body {
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
color: var(--danger-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -337,8 +356,8 @@ body {
|
||||
padding: 2rem 1rem 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: var(--muted-foreground);
|
||||
border-top: 1px solid var(--border);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -347,7 +366,7 @@ body {
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
color: #000;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -375,7 +394,7 @@ body {
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
margin: 0 0 1rem 0;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.statistics-grid {
|
||||
@@ -391,7 +410,7 @@ body {
|
||||
padding: 0.75rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
@@ -401,7 +420,7 @@ body {
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -409,7 +428,7 @@ body {
|
||||
.stat-count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hördle",
|
||||
description: "Daily music guessing game - Guess the song from short audio clips",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#000000",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<AppFooter />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Hördle',
|
||||
short_name: 'Hördle',
|
||||
description: 'Daily music guessing game - Guess the song from short audio clips',
|
||||
name: config.appName,
|
||||
short_name: config.appName,
|
||||
description: config.appDescription,
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#000000',
|
||||
background_color: config.colors.backgroundColor,
|
||||
theme_color: config.colors.themeColor,
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.ico',
|
||||
|
||||
99
app/page.tsx
99
app/page.tsx
@@ -1,99 +0,0 @@
|
||||
import Game from '@/components/Game';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function Home() {
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
||||
const isEnded = s.endDate && s.endDate < now;
|
||||
return isStarted && !isEnded;
|
||||
});
|
||||
|
||||
const upcomingSpecials = specials.filter(s => {
|
||||
return s.launchDate && s.launchDate > now;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div className="tooltip">
|
||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
||||
<span className="tooltip-text">A random song from the entire collection</span>
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => (
|
||||
<div key={g.id} className="tooltip">
|
||||
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||
{g.name}
|
||||
</Link>
|
||||
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Active Specials */}
|
||||
{activeSpecials.map(s => (
|
||||
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div className="tooltip">
|
||||
<Link
|
||||
href={`/special/${s.name}`}
|
||||
style={{
|
||||
color: '#be185d', // Pink-700
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {s.name}
|
||||
</Link>
|
||||
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
|
||||
</div>
|
||||
{s.curator && (
|
||||
<span style={{ fontSize: '0.75rem', color: '#666' }}>
|
||||
Curated by {s.curator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Specials */}
|
||||
{upcomingSpecials.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
||||
Coming soon: {upcomingSpecials.map(s => (
|
||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||
★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: process.env.TZ
|
||||
}) : ''})
|
||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
app/robots.ts
Normal file
20
app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
const siteUrl = `${protocol}://${baseUrl}`;
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/'],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
136
app/sitemap.ts
Normal file
136
app/sitemap.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
const siteUrl = `${protocol}://${baseUrl}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${siteUrl}/en`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 1.0,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de`,
|
||||
'en': `${siteUrl}/en`,
|
||||
'x-default': `${siteUrl}/en`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de`,
|
||||
'en': `${siteUrl}/en`,
|
||||
'x-default': `${siteUrl}/en`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/en/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/about`,
|
||||
'en': `${siteUrl}/en/about`,
|
||||
'x-default': `${siteUrl}/en/about`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/about`,
|
||||
'en': `${siteUrl}/en/about`,
|
||||
'x-default': `${siteUrl}/en/about`,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Dynamic genre pages
|
||||
try {
|
||||
// Während des Docker-Builds wird häufig eine temporäre SQLite-DB (file:./dev.db)
|
||||
// ohne migrierte Tabellen verwendet. In diesem Fall überspringen wir die
|
||||
// Datenbankabfrage und liefern nur die statischen Seiten, um Build-Fehler zu vermeiden.
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (dbUrl && dbUrl.startsWith('file:./')) {
|
||||
return staticPages;
|
||||
}
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
});
|
||||
|
||||
const genrePages: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const genre of genres) {
|
||||
const genreNameEn = getLocalizedValue(genre.name, 'en');
|
||||
const genreNameDe = getLocalizedValue(genre.name, 'de');
|
||||
|
||||
// Only add if genre name is valid
|
||||
if (genreNameEn && genreNameDe) {
|
||||
const encodedEn = encodeURIComponent(genreNameEn);
|
||||
const encodedDe = encodeURIComponent(genreNameDe);
|
||||
|
||||
genrePages.push(
|
||||
{
|
||||
url: `${siteUrl}/en/${encodedEn}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/${encodedDe}`,
|
||||
'en': `${siteUrl}/en/${encodedEn}`,
|
||||
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de/${encodedDe}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/${encodedDe}`,
|
||||
'en': `${siteUrl}/en/${encodedEn}`,
|
||||
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...staticPages, ...genrePages];
|
||||
} catch (error) {
|
||||
console.error('Error generating sitemap:', error);
|
||||
// Return static pages only if database query fails
|
||||
return staticPages;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import Game from '@/components/Game';
|
||||
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ name: string }>;
|
||||
}
|
||||
|
||||
export default async function SpecialPage({ params }: PageProps) {
|
||||
const { name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
const currentSpecial = await prisma.special.findUnique({
|
||||
where: { name: decodedName }
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
|
||||
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
|
||||
|
||||
if (!currentSpecial || !isStarted) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h1>Special Not Available</h1>
|
||||
<p>This special has not launched yet or does not exist.</p>
|
||||
<Link href="/">Go Home</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEnded) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h1>Special Ended</h1>
|
||||
<p>This special event has ended.</p>
|
||||
<Link href="/">Go Home</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
|
||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const sStarted = !s.launchDate || s.launchDate <= now;
|
||||
const sEnded = s.endDate && s.endDate < now;
|
||||
return sStarted && !sEnded;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${g.name}`}
|
||||
style={{
|
||||
color: '#4b5563',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Specials */}
|
||||
{activeSpecials.map(s => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/special/${s.name}`}
|
||||
style={{
|
||||
fontWeight: s.name === decodedName ? 'bold' : 'normal',
|
||||
textDecoration: s.name === decodedName ? 'underline' : 'none',
|
||||
color: s.name === decodedName ? '#9d174d' : '#be185d'
|
||||
}}
|
||||
>
|
||||
★ {s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Game
|
||||
dailyPuzzle={dailyPuzzle}
|
||||
genre={decodedName}
|
||||
isSpecial={true}
|
||||
maxAttempts={dailyPuzzle?.maxAttempts}
|
||||
unlockSteps={dailyPuzzle?.unlockSteps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const t = useTranslations("About");
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/version')
|
||||
.then(res => res.json())
|
||||
.then(data => setVersion(data.version))
|
||||
.catch(() => setVersion(''));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetch("/api/version")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setVersion(data.version))
|
||||
.catch(() => setVersion(""));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Vibe coded with ☕ and 🍺 by{' '}
|
||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||
@elpatron@digitalcourage.social
|
||||
</a>
|
||||
{' '}- for personal use among friends only!
|
||||
{version && (
|
||||
<>
|
||||
{' '}·{' '}
|
||||
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
|
||||
{version}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
if (!config.credits.enabled) return null;
|
||||
|
||||
return (
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
{config.credits.text}{" "}
|
||||
<a
|
||||
href={config.credits.linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{config.credits.linkText}
|
||||
</a>
|
||||
{version && (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<span style={{ fontSize: "0.85em", opacity: 0.7 }}>{version}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>
|
||||
<Link href="/about" style={{ textDecoration: "underline" }}>
|
||||
{t("footerLinkLabel")}
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
@@ -9,39 +9,112 @@ interface AudioPlayerProps {
|
||||
onPlay?: () => void;
|
||||
onReplay?: () => void;
|
||||
autoPlay?: boolean;
|
||||
onHasPlayedChange?: (hasPlayed: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
|
||||
export interface AudioPlayerRef {
|
||||
play: () => void;
|
||||
}
|
||||
|
||||
const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false, onHasPlayedChange }, ref) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
||||
|
||||
const [processedSrc, setProcessedSrc] = useState(src);
|
||||
const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState(unlockedSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[AudioPlayer] MOUNTED');
|
||||
return () => console.log('[AudioPlayer] UNMOUNTED');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = startTime;
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
setHasPlayedOnce(false); // Reset for new segment
|
||||
// Check if props changed compared to what we last processed
|
||||
const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
|
||||
|
||||
if (autoPlay) {
|
||||
const playPromise = audioRef.current.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
onPlay?.();
|
||||
setHasPlayedOnce(true);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log("Autoplay prevented:", error);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
if (hasChanged) {
|
||||
audioRef.current.pause();
|
||||
|
||||
let startPos = startTime;
|
||||
|
||||
// If same song but more time unlocked, start from where previous segment ended
|
||||
if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
|
||||
startPos = startTime + processedUnlockedSeconds;
|
||||
}
|
||||
|
||||
const targetPos = startPos;
|
||||
audioRef.current.currentTime = targetPos;
|
||||
|
||||
// Ensure position is set correctly even if browser resets it
|
||||
setTimeout(() => {
|
||||
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
|
||||
audioRef.current.currentTime = targetPos;
|
||||
}
|
||||
}, 50);
|
||||
|
||||
setIsPlaying(false);
|
||||
|
||||
// Calculate initial progress
|
||||
const initialElapsed = startPos - startTime;
|
||||
const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0;
|
||||
setProgress(Math.min(initialPercent, 100));
|
||||
|
||||
setHasPlayedOnce(false); // Reset for new segment
|
||||
onHasPlayedChange?.(false); // Notify parent
|
||||
|
||||
// Update processed state
|
||||
setProcessedSrc(src);
|
||||
setProcessedUnlockedSeconds(unlockedSeconds);
|
||||
|
||||
if (autoPlay) {
|
||||
// Delay play slightly to ensure currentTime sticks
|
||||
setTimeout(() => {
|
||||
const playPromise = audioRef.current?.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
onPlay?.();
|
||||
setHasPlayedOnce(true);
|
||||
onHasPlayedChange?.(true); // Notify parent
|
||||
})
|
||||
.catch(error => {
|
||||
console.log("Autoplay prevented:", error);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [src, unlockedSeconds, startTime, autoPlay]);
|
||||
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
|
||||
|
||||
// Expose play method to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
const playPromise = audioRef.current.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
onPlay?.();
|
||||
if (!hasPlayedOnce) {
|
||||
setHasPlayedOnce(true);
|
||||
onHasPlayedChange?.(true);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Play failed:", error);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return;
|
||||
@@ -56,6 +129,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
onReplay?.();
|
||||
} else {
|
||||
setHasPlayedOnce(true);
|
||||
onHasPlayedChange?.(true); // Notify parent
|
||||
}
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
@@ -112,4 +186,10 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
AudioPlayer.displayName = 'AudioPlayer';
|
||||
|
||||
|
||||
|
||||
export default AudioPlayer;
|
||||
|
||||
212
components/CurateSpecialEditor.tsx
Normal file
212
components/CurateSpecialEditor.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'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) {
|
||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(
|
||||
special.songs.length > 0 ? special.songs[0].songId : null
|
||||
);
|
||||
const [pendingStartTime, setPendingStartTime] = useState<number | null>(
|
||||
special.songs.length > 0 ? special.songs[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 = special.songs.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' }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '1rem'
|
||||
}}
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{special.songs.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' }}>
|
||||
{special.songs.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 && (
|
||||
<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={`/uploads/${selectedSpecialSong.song.filename}`}
|
||||
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
||||
duration={totalDuration}
|
||||
unlockSteps={unlockSteps}
|
||||
onStartTimeChange={handleStartTimeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
import { config } from '@/lib/config';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||
import GuessInput from './GuessInput';
|
||||
import Statistics from './Statistics';
|
||||
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||
import { useGameState } from '../lib/gameState';
|
||||
import { getGenreKey } from '@/lib/playerStorage';
|
||||
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
||||
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
||||
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||
import { getOrCreatePlayerId } from '@/lib/playerId';
|
||||
|
||||
// Plausible Analytics
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||
}
|
||||
}
|
||||
|
||||
interface GameProps {
|
||||
dailyPuzzle: {
|
||||
@@ -23,20 +38,36 @@ interface GameProps {
|
||||
isSpecial?: boolean;
|
||||
maxAttempts?: number;
|
||||
unlockSteps?: number[];
|
||||
// List of genre keys that zusammen alle Tagesrätsel des Tages repräsentieren (z. B. ['global', 'Rock', 'Pop']).
|
||||
// Wird genutzt, um zu prüfen, ob der Spieler alle Tagesrätsel gespielt hat.
|
||||
requiredDailyKeys?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||
|
||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
||||
const [hasWon, setHasWon] = useState(false);
|
||||
const [hasLost, setHasLost] = useState(false);
|
||||
const [shareText, setShareText] = useState('🔗 Share');
|
||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS, requiredDailyKeys }: GameProps) {
|
||||
const t = useTranslations('Game');
|
||||
const locale = useLocale();
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||
const [hasWon, setHasWon] = useState(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 [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||
const [hasRated, setHasRated] = useState(false);
|
||||
const [showYearModal, setShowYearModal] = useState(false);
|
||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||
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(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -57,50 +88,105 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState && dailyPuzzle) {
|
||||
if (gameState) {
|
||||
setHasWon(gameState.isSolved);
|
||||
setHasLost(gameState.isFailed);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}, [gameState, dailyPuzzle]);
|
||||
|
||||
// Track gespielte Tagesrätsel & entscheide, ob das Partner-Popover gezeigt werden soll
|
||||
useEffect(() => {
|
||||
if (!gameState || !dailyPuzzle) return;
|
||||
|
||||
const gameEnded = gameState.isSolved || gameState.isFailed;
|
||||
if (!gameEnded) return;
|
||||
|
||||
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
markDailyPuzzlePlayedToday(genreKey);
|
||||
|
||||
if (!requiredDailyKeys || requiredDailyKeys.length === 0) return;
|
||||
if (hasSeenExtraPuzzlesPopoverToday()) return;
|
||||
if (!hasPlayedAllDailyPuzzlesForToday(requiredDailyKeys)) return;
|
||||
|
||||
const partnerPuzzle = getRandomExternalPuzzle();
|
||||
if (!partnerPuzzle) return;
|
||||
|
||||
setExtraPuzzle(partnerPuzzle);
|
||||
setShowExtraPuzzlesPopover(true);
|
||||
markExtraPuzzlesPopoverShownToday();
|
||||
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('extra_puzzles_popover_shown', {
|
||||
props: {
|
||||
partner: partnerPuzzle.id,
|
||||
url: partnerPuzzle.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [gameState?.isSolved, gameState?.isFailed, dailyPuzzle?.id, genre, isSpecial, requiredDailyKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setLastAction(null);
|
||||
}, [dailyPuzzle?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dailyPuzzle) {
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
|
||||
if (ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||
setHasRated(true);
|
||||
} else {
|
||||
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]);
|
||||
|
||||
if (!dailyPuzzle) return (
|
||||
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h2>No Puzzle Available</h2>
|
||||
<p>Could not generate a daily puzzle.</p>
|
||||
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
|
||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
||||
<h2>{t('noPuzzleAvailable')}</h2>
|
||||
<p>{t('noPuzzleDescription')}</p>
|
||||
<p>{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.</p>
|
||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{t('goToAdmin')}</a>
|
||||
</div>
|
||||
);
|
||||
if (!gameState) return <div>Loading state...</div>;
|
||||
if (!gameState) return <div>{t('loadingState')}</div>;
|
||||
|
||||
const handleGuess = (song: any) => {
|
||||
if (isProcessingGuess) return;
|
||||
// Prevent guessing if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessingGuess(true);
|
||||
setLastAction('GUESS');
|
||||
if (song.id === dailyPuzzle.songId) {
|
||||
addGuess(song.title, true);
|
||||
setHasWon(true);
|
||||
// Track puzzle solved event
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: gameState.guesses.length + 1,
|
||||
score: gameState.score + 20, // Include the win bonus
|
||||
outcome: 'won'
|
||||
}
|
||||
});
|
||||
}
|
||||
// Notification sent after year guess or skip
|
||||
if (!dailyPuzzle.releaseYear) {
|
||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
@@ -110,29 +196,81 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
// Track puzzle lost event
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: maxAttempts,
|
||||
score: 0,
|
||||
outcome: 'lost'
|
||||
}
|
||||
});
|
||||
}
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||
}
|
||||
}
|
||||
setTimeout(() => setIsProcessingGuess(false), 500);
|
||||
};
|
||||
|
||||
const handleStartAudio = () => {
|
||||
// This will be called when user clicks "Start" button on first attempt
|
||||
// Trigger the audio player to start playing
|
||||
audioPlayerRef.current?.play();
|
||||
setHasPlayedAudio(true);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Prevent skipping if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||
|
||||
// If user hasn't played audio yet on first attempt, start it instead of skipping
|
||||
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||||
handleStartAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
// Track puzzle lost event
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: maxAttempts,
|
||||
score: 0,
|
||||
outcome: 'lost'
|
||||
}
|
||||
});
|
||||
}
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||
}
|
||||
};
|
||||
|
||||
const handleGiveUp = () => {
|
||||
// Prevent giving up if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
// Track puzzle lost event
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: gameState.guesses.length + 1,
|
||||
score: 0,
|
||||
outcome: 'lost'
|
||||
}
|
||||
});
|
||||
}
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||
};
|
||||
|
||||
@@ -141,6 +279,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
addYearBonus(correct);
|
||||
setShowYearModal(false);
|
||||
|
||||
// Update the puzzle_solved event with year bonus result
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: gameState.guesses.length,
|
||||
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
|
||||
outcome: 'won',
|
||||
year_bonus: correct ? 'correct' : 'incorrect'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||
};
|
||||
@@ -148,10 +299,101 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const handleYearSkip = () => {
|
||||
skipYearBonus();
|
||||
setShowYearModal(false);
|
||||
|
||||
// Update the puzzle_solved event with year bonus result
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: gameState.guesses.length,
|
||||
score: gameState.score, // Score already includes win bonus
|
||||
outcome: 'won',
|
||||
year_bonus: 'skipped'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
|
||||
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) {
|
||||
setRewrittenMessage(finalMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 handleShare = async () => {
|
||||
@@ -160,23 +402,44 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
for (let i = 0; i < totalGuesses; i++) {
|
||||
if (i < gameState.guesses.length) {
|
||||
if (hasWon && i === gameState.guesses.length - 1) {
|
||||
emojiGrid += '🟩';
|
||||
} else if (gameState.guesses[i] === 'SKIPPED') {
|
||||
if (gameState.guesses[i] === 'SKIPPED') {
|
||||
emojiGrid += '⬛';
|
||||
} else if (hasWon && i === gameState.guesses.length - 1) {
|
||||
emojiGrid += '🟩';
|
||||
} else {
|
||||
emojiGrid += '🟥';
|
||||
}
|
||||
} else {
|
||||
emojiGrid += '⬜';
|
||||
// If game is lost, fill remaining slots with black squares
|
||||
emojiGrid += hasLost ? '⬛' : '⬜';
|
||||
}
|
||||
}
|
||||
|
||||
const speaker = hasWon ? '🔉' : '🔇';
|
||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
||||
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||
|
||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de
|
||||
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 (isSpecial) {
|
||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||||
@@ -185,7 +448,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}
|
||||
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\n${t('score')}: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
|
||||
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
@@ -195,8 +458,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
||||
text: text,
|
||||
});
|
||||
setShareText('✓ Shared!');
|
||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||
setShareText(t('shared'));
|
||||
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||
return;
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
@@ -207,12 +470,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setShareText('✓ Copied!');
|
||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||
setShareText(t('copied'));
|
||||
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||
} catch (err) {
|
||||
console.error('Clipboard failed:', err);
|
||||
setShareText('✗ Failed');
|
||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||
setShareText(t('shareFailed'));
|
||||
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,41 +486,54 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||
setHasRated(true);
|
||||
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
|
||||
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||
ratedPuzzles.push(dailyPuzzle.id);
|
||||
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles));
|
||||
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit rating', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Aktuelle Attempt-Anzeige:
|
||||
// - Während des Spiels: nächster Versuch = guesses.length + 1
|
||||
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
|
||||
const currentAttempt = (gameState.isSolved || gameState.isFailed)
|
||||
? gameState.guesses.length
|
||||
: gameState.guesses.length + 1;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="header">
|
||||
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '-0.5rem', marginBottom: '1rem' }}>
|
||||
Next puzzle in: {timeUntilNext}
|
||||
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||
{t('nextPuzzle')}: {timeUntilNext}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="game-board">
|
||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||
<div className="status-bar">
|
||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s unlocked</span>
|
||||
<div id="tour-status" className="status-bar">
|
||||
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||
</div>
|
||||
|
||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||
<div id="tour-score">
|
||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||
</div>
|
||||
|
||||
<AudioPlayer
|
||||
src={dailyPuzzle.audioUrl}
|
||||
unlockedSeconds={unlockedSeconds}
|
||||
startTime={dailyPuzzle.startTime}
|
||||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||
onReplay={addReplay}
|
||||
/>
|
||||
<div id="tour-player">
|
||||
<AudioPlayer
|
||||
ref={audioPlayerRef}
|
||||
src={dailyPuzzle.audioUrl}
|
||||
unlockedSeconds={unlockedSeconds}
|
||||
startTime={dailyPuzzle.startTime}
|
||||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||
onReplay={addReplay}
|
||||
onHasPlayedChange={setHasPlayedAudio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="guess-list">
|
||||
@@ -267,7 +543,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
<div key={i} className="guess-item">
|
||||
<span className="guess-number">#{i + 1}</span>
|
||||
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
||||
{isCorrect ? 'Correct!' : guess}
|
||||
{isCorrect ? t('correct') : guess}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -276,13 +552,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
{!hasWon && !hasLost && (
|
||||
<>
|
||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||
<div id="tour-input">
|
||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||
</div>
|
||||
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||
<button
|
||||
id="tour-controls"
|
||||
onClick={handleSkip}
|
||||
className="skip-button"
|
||||
>
|
||||
Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
|
||||
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||||
? t('start')
|
||||
: t('skipWithBonus', { seconds: unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds })
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -293,7 +575,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
||||
}}
|
||||
>
|
||||
Solve (Give Up)
|
||||
{t('solveGiveUp')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -302,15 +584,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
{(hasWon || hasLost) && (
|
||||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
{hasWon ? 'You won!' : 'Game Over'}
|
||||
{hasWon ? t('won') : t('lost')}
|
||||
</h2>
|
||||
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
||||
Score: {gameState.score}
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{t('score')}: {gameState.score}
|
||||
</div>
|
||||
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
||||
<summary>Score Breakdown</summary>
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
|
||||
<summary>{t('scoreBreakdown')}</summary>
|
||||
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||
{gameState.scoreBreakdown.map((item, i) => (
|
||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||||
@@ -323,33 +605,141 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
|
||||
<p>{hasWon ? t('comeBackTomorrow') : t('theSongWas')}</p>
|
||||
|
||||
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<img
|
||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||
alt="Album Cover"
|
||||
alt={t('albumCover')}
|
||||
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
||||
/>
|
||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>{t('released')}: {dailyPuzzle.releaseYear}</p>
|
||||
)}
|
||||
<audio controls style={{ width: '100%' }}>
|
||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
{t('yourBrowserDoesNotSupport')}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||
</div>
|
||||
|
||||
<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' // Ensure block display for proper alignment
|
||||
}}
|
||||
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} />}
|
||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||
{shareText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@@ -361,6 +751,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
onSkip={handleYearSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||
<ExtraPuzzlesPopover
|
||||
puzzle={extraPuzzle}
|
||||
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -385,20 +782,22 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
||||
textAlign: 'center',
|
||||
margin: '0.5rem 0',
|
||||
padding: '0.5rem',
|
||||
background: '#f3f4f6',
|
||||
background: 'var(--muted)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'monospace',
|
||||
cursor: 'help'
|
||||
}}>
|
||||
<span style={{ color: '#666' }}>{expression} = </span>
|
||||
<span style={{ color: 'var(--muted-foreground)' }}>{expression} = </span>
|
||||
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||
const t = useTranslations('Game');
|
||||
const [options, setOptions] = useState<number[]>([]);
|
||||
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
|
||||
|
||||
useEffect(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -427,6 +826,24 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||
}, [correctYear]);
|
||||
|
||||
const handleGuess = (year: number) => {
|
||||
const correct = year === correctYear;
|
||||
setFeedback({ show: true, correct, guessedYear: year });
|
||||
|
||||
// Close modal after showing feedback
|
||||
setTimeout(() => {
|
||||
onGuess(year);
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setFeedback({ show: true, correct: false });
|
||||
|
||||
setTimeout(() => {
|
||||
onSkip();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
@@ -450,67 +867,102 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
||||
{!feedback.show ? (
|
||||
<>
|
||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>{t('bonusRound')}</h3>
|
||||
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>{t('guessReleaseYear')} <strong style={{ color: 'var(--success)' }}>+10 {t('points')}</strong>!</p>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
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
|
||||
key={year}
|
||||
onClick={() => onGuess(year)}
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: '#f3f4f6',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--muted-foreground)',
|
||||
textDecoration: 'underline',
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Skip Bonus
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
{feedback.guessedYear ? (
|
||||
feedback.correct ? (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>{t('correct')}</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
|
||||
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 {t('points')}!</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>{t('notQuite')}</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('youGuessed')} {feedback.guessedYear}</p>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>{t('actuallyReleasedIn')} <strong>{correctYear}</strong></p>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>⏭️</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>{t('skipped')}</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
||||
const t = useTranslations('Game');
|
||||
const [hover, setHover] = useState(0);
|
||||
const [rating, setRating] = useState(0);
|
||||
|
||||
if (hasRated) {
|
||||
return <div style={{ color: '#666', fontStyle: 'italic' }}>Thanks for rating!</div>;
|
||||
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>{t('thanksForRating')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: '#666', fontWeight: '500' }}>Rate this puzzle:</span>
|
||||
<div
|
||||
className="star-rating"
|
||||
title={t('ratingTooltip')}
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||
{[...Array(5)].map((_, index) => {
|
||||
const ratingValue = index + 1;
|
||||
@@ -523,7 +975,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '2rem',
|
||||
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
|
||||
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
|
||||
transition: 'color 0.2s',
|
||||
padding: '0 0.25rem'
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface Song {
|
||||
id: number;
|
||||
@@ -14,15 +15,32 @@ interface GuessInputProps {
|
||||
}
|
||||
|
||||
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||
const t = useTranslations('Game');
|
||||
const [query, setQuery] = useState('');
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/songs')
|
||||
.then(res => res.json())
|
||||
.then(data => setSongs(data));
|
||||
fetch('/api/public-songs')
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load songs: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
setSongs(data);
|
||||
} else {
|
||||
console.error('Unexpected songs payload in GuessInput:', data);
|
||||
setSongs([]);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading songs for GuessInput:', err);
|
||||
setSongs([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +71,7 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
|
||||
placeholder={disabled ? t('gameOverPlaceholder') : t('knowItSearch')}
|
||||
className="guess-input"
|
||||
/>
|
||||
|
||||
|
||||
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';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function InstallPrompt() {
|
||||
const t = useTranslations('InstallPrompt');
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [isStandalone, setIsStandalone] = useState(false);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
@@ -80,9 +82,9 @@ export default function InstallPrompt() {
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>Install Hördle App</h3>
|
||||
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>{t('installApp')}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
||||
Install the app for a better experience and quick access!
|
||||
{t('installDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -102,7 +104,7 @@ export default function InstallPrompt() {
|
||||
|
||||
{isIOS ? (
|
||||
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||
Tap <span style={{ fontSize: '1.2rem' }}>share</span> then "Add to Home Screen" <span style={{ fontSize: '1.2rem' }}>+</span>
|
||||
{t('iosInstructions')} <span style={{ fontSize: '1.2rem' }}>{t('iosShare')}</span> {t('iosThen')} <span style={{ fontSize: '1.2rem' }}>+</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -118,7 +120,7 @@ export default function InstallPrompt() {
|
||||
marginTop: '0.5rem'
|
||||
}}
|
||||
>
|
||||
Install App
|
||||
{t('installButton')}
|
||||
</button>
|
||||
)}
|
||||
<style jsx>{`
|
||||
|
||||
59
components/LanguageSwitcher.tsx
Normal file
59
components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from '@/lib/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const switchLocale = (newLocale: 'de' | 'en') => {
|
||||
router.replace(pathname, { locale: newLocale });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.25rem',
|
||||
gap: '0.25rem'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => switchLocale('de')}
|
||||
style={{
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: locale === 'de' ? 'white' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: locale === 'de' ? '600' : '400',
|
||||
fontSize: '0.875rem',
|
||||
color: locale === 'de' ? '#111827' : '#6b7280',
|
||||
boxShadow: locale === 'de' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
DE
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchLocale('en')}
|
||||
style={{
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: locale === 'en' ? 'white' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: locale === 'en' ? '600' : '400',
|
||||
fontSize: '0.875rem',
|
||||
color: locale === 'en' ? '#111827' : '#6b7280',
|
||||
boxShadow: locale === 'en' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
components/NewsSection.tsx
Normal file
204
components/NewsSection.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
interface NewsItem {
|
||||
id: number;
|
||||
title: any;
|
||||
content: any;
|
||||
author: string | null;
|
||||
publishedAt: string;
|
||||
featured: boolean;
|
||||
special: {
|
||||
id: number;
|
||||
name: any;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface NewsSectionProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export default function NewsSection({ locale }: NewsSectionProps) {
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNews();
|
||||
}, [locale]);
|
||||
|
||||
const fetchNews = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/news?limit=3&locale=${locale}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNews(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch news:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || news.length === 0) {
|
||||
return null; // Don't show anything if no news
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#f9fafb',
|
||||
borderRadius: '0.5rem',
|
||||
margin: '1rem auto',
|
||||
maxWidth: '800px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}
|
||||
>
|
||||
<span>📰 News & Updates</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div style={{
|
||||
padding: '0 1rem 1rem 1rem',
|
||||
borderTop: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{news.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: index < news.length - 1 ? '1px solid #e5e7eb' : 'none'
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.25rem'
|
||||
}}>
|
||||
{item.featured && (
|
||||
<span style={{
|
||||
background: '#fef3c7',
|
||||
color: '#92400e',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
⭐ FEATURED
|
||||
</span>
|
||||
)}
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: '#111827'
|
||||
}}>
|
||||
{getLocalizedValue(item.title, locale)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#6b7280',
|
||||
marginBottom: '0.5rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<span>
|
||||
{new Date(item.publishedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
{item.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>by {item.author}</span>
|
||||
</>
|
||||
)}
|
||||
{item.special && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Link
|
||||
href={`/special/${getLocalizedValue(item.special.name, locale)}`}
|
||||
style={{
|
||||
color: '#be185d',
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {getLocalizedValue(item.special.name, locale)}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="news-content"
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
color: '#374151',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p style={{ margin: '0.5rem 0' }}>{children}</p>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#4f46e5', textDecoration: 'underline' }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => <strong style={{ fontWeight: '600' }}>{children}</strong>,
|
||||
em: ({ children }) => <em style={{ fontStyle: 'italic' }}>{children}</em>,
|
||||
ul: ({ children }) => <ul style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ul>,
|
||||
ol: ({ children }) => <ol style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ol>,
|
||||
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
||||
}}
|
||||
>
|
||||
{getLocalizedValue(item.content, locale)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
components/OnboardingTour.tsx
Normal file
112
components/OnboardingTour.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { driver } from 'driver.js';
|
||||
import 'driver.js/dist/driver.css';
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const t = useTranslations('OnboardingTour');
|
||||
|
||||
useEffect(() => {
|
||||
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
|
||||
|
||||
if (hasCompletedOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const driverObj = driver({
|
||||
showProgress: true,
|
||||
animate: true,
|
||||
allowClose: true,
|
||||
doneBtnText: t('done'),
|
||||
nextBtnText: t('next'),
|
||||
prevBtnText: t('previous'),
|
||||
onDestroyed: () => {
|
||||
localStorage.setItem('hoerdle_onboarding_completed', 'true');
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
element: '#tour-genres',
|
||||
popover: {
|
||||
title: t('genresSpecials'),
|
||||
description: t('genresSpecialsDescription'),
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-news',
|
||||
popover: {
|
||||
title: t('news'),
|
||||
description: t('newsDescription'),
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-title',
|
||||
popover: {
|
||||
title: t('hoerdle'),
|
||||
description: t('hoerdleDescription'),
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-status',
|
||||
popover: {
|
||||
title: t('attempts'),
|
||||
description: t('attemptsDescription'),
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-score',
|
||||
popover: {
|
||||
title: t('score'),
|
||||
description: t('scoreDescription'),
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-player',
|
||||
popover: {
|
||||
title: t('player'),
|
||||
description: t('playerDescription'),
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-input',
|
||||
popover: {
|
||||
title: t('input'),
|
||||
description: t('inputDescription'),
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-controls',
|
||||
popover: {
|
||||
title: t('controls'),
|
||||
description: t('controlsDescription'),
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
driverObj.drive();
|
||||
}, 1000);
|
||||
|
||||
}, [t]);
|
||||
|
||||
return null;
|
||||
}
|
||||
95
components/PoliticalStatementBanner.tsx
Normal file
95
components/PoliticalStatementBanner.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
interface ApiStatement {
|
||||
id: number;
|
||||
text: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export default function PoliticalStatementBanner() {
|
||||
const locale = useLocale();
|
||||
const [statement, setStatement] = useState<ApiStatement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const storageKey = `hoerdle_political_statement_shown_${today}_${locale}`;
|
||||
|
||||
try {
|
||||
const alreadyShown = typeof window !== 'undefined' && window.localStorage.getItem(storageKey);
|
||||
if (alreadyShown) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const fetchStatement = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(locale)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data || !data.text) return;
|
||||
setStatement(data);
|
||||
setVisible(true);
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, 'true');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 5000);
|
||||
} catch (e) {
|
||||
console.warn('[PoliticalStatementBanner] Failed to load statement', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatement();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
if (!visible || !statement) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '1.25rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: '640px',
|
||||
width: 'calc(100% - 2.5rem)',
|
||||
zIndex: 1050,
|
||||
background: 'rgba(17,24,39,0.95)',
|
||||
color: '#e5e7eb',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
boxShadow: '0 10px 25px rgba(0,0,0,0.45)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.9rem' }}>✊</span>
|
||||
<span style={{ flex: 1 }}>{statement.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Statistics as StatsType } from '../lib/gameState';
|
||||
|
||||
interface StatisticsProps {
|
||||
@@ -18,6 +19,7 @@ const BADGES = {
|
||||
};
|
||||
|
||||
export default function Statistics({ statistics }: StatisticsProps) {
|
||||
const t = useTranslations('Statistics');
|
||||
const total =
|
||||
statistics.solvedIn1 +
|
||||
statistics.solvedIn2 +
|
||||
@@ -36,19 +38,19 @@ export default function Statistics({ statistics }: StatisticsProps) {
|
||||
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
||||
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
||||
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
|
||||
{ attempts: 'Failed', count: statistics.failed, badge: BADGES.failed },
|
||||
{ attempts: t('failed'), count: statistics.failed, badge: BADGES.failed },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="statistics-container">
|
||||
<h3 className="statistics-title">Your Statistics</h3>
|
||||
<p className="statistics-total">Total puzzles: {total}</p>
|
||||
<h3 className="statistics-title">{t('yourStatistics')}</h3>
|
||||
<p className="statistics-total">{t('totalPuzzles')}: {total}</p>
|
||||
<div className="statistics-grid">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="stat-item">
|
||||
<div className="stat-badge">{stat.badge}</div>
|
||||
<div className="stat-label">
|
||||
{typeof stat.attempts === 'number' ? `${stat.attempts} try` : stat.attempts}
|
||||
{typeof stat.attempts === 'number' ? `${stat.attempts} ${t('try')}` : stat.attempts}
|
||||
</div>
|
||||
<div className="stat-count">{stat.count}</div>
|
||||
</div>
|
||||
|
||||
59
docker-compose.caddy.yml
Normal file
59
docker-compose.caddy.yml
Normal file
@@ -0,0 +1,59 @@
|
||||
# Docker Compose Konfiguration für Caddy Reverse Proxy
|
||||
# Optional: Nur in Produktionsumgebung verwenden
|
||||
#
|
||||
# Starten: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
||||
# Stoppen: docker compose -f docker-compose.yml -f docker-compose.caddy.yml down
|
||||
|
||||
services:
|
||||
caddy:
|
||||
# Verwende Custom-Image mit GoDaddy DNS-Plugin
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.caddy
|
||||
# Alternativ: Verwende Standard-Caddy und manuelle DNS-Konfiguration
|
||||
# image: caddy:2-alpine
|
||||
container_name: hoerdle-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Standard HTTP/HTTPS Ports
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # Für HTTP/3 (QUIC)
|
||||
environment:
|
||||
# GoDaddy API-Credentials für DNS-01 Challenge
|
||||
# Diese müssen in einer .env-Datei gesetzt werden:
|
||||
# GODADDY_API_KEY=your_api_key
|
||||
# GODADDY_API_SECRET=your_api_secret
|
||||
- GODADDY_API_KEY=${GODADDY_API_KEY:-}
|
||||
- GODADDY_API_SECRET=${GODADDY_API_SECRET:-}
|
||||
# Optional: Email für Let's Encrypt Benachrichtigungen
|
||||
- CADDY_EMAIL=${CADDY_EMAIL:-}
|
||||
volumes:
|
||||
# Caddyfile-Konfiguration
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
# Persistente Zertifikat-Speicherung
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- default
|
||||
# Health Check
|
||||
healthcheck:
|
||||
test: ["CMD", "caddy", "version"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# Nur starten, wenn ENABLE_CADDY=true gesetzt ist
|
||||
profiles:
|
||||
- production
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
driver: local
|
||||
caddy_config:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: hoerdle_default
|
||||
external: true
|
||||
|
||||
@@ -4,6 +4,17 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
||||
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
||||
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
|
||||
NEXT_PUBLIC_CREDITS_ENABLED: ${NEXT_PUBLIC_CREDITS_ENABLED}
|
||||
NEXT_PUBLIC_CREDITS_TEXT: ${NEXT_PUBLIC_CREDITS_TEXT}
|
||||
NEXT_PUBLIC_CREDITS_LINK_TEXT: ${NEXT_PUBLIC_CREDITS_LINK_TEXT}
|
||||
NEXT_PUBLIC_CREDITS_LINK_URL: ${NEXT_PUBLIC_CREDITS_LINK_URL}
|
||||
user: root
|
||||
restart: always
|
||||
ports:
|
||||
@@ -24,6 +35,11 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
# Run migrations and start server (auto-baseline on first run if needed)
|
||||
command: >
|
||||
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"
|
||||
networks:
|
||||
- default
|
||||
# docker-entrypoint.sh handles migrations and server startup (with baseline fallback)
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: hoerdle_default
|
||||
external: true
|
||||
|
||||
289
docs/CADDY_SETUP.md
Normal file
289
docs/CADDY_SETUP.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Caddy-Setup für Hördle
|
||||
|
||||
Diese Anleitung erklärt, wie du Caddy als Reverse-Proxy mit automatischen Let's Encrypt Wildcard-Zertifikaten für die Domains `hoerdle.de` und `hördle.de` (xn--hrdle-jua.de) einrichtest.
|
||||
|
||||
## Übersicht
|
||||
|
||||
Caddy übernimmt folgende Aufgaben:
|
||||
- Automatische SSL/TLS-Zertifikate via Let's Encrypt
|
||||
- Wildcard-Zertifikate für beide Domains (inkl. Subdomains)
|
||||
- Reverse Proxy zu deinem Hördle-Container
|
||||
- HTTP zu HTTPS Redirect
|
||||
- Optimierte Einstellungen für Audio-Streaming und Uploads
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
1. Docker und Docker Compose installiert
|
||||
2. Zugriff auf deine GoDaddy Domain-Verwaltung
|
||||
3. Ports 80 und 443 müssen frei sein (Caddy übernimmt diese)
|
||||
|
||||
## Schritt 1: GoDaddy DNS-API-Zugangsdaten erstellen
|
||||
|
||||
Für Wildcard-Zertifikate benötigt Caddy DNS-01 Challenge, was API-Zugriff auf dein GoDaddy-Konto erfordert.
|
||||
|
||||
### GoDaddy API-Keys erstellen
|
||||
|
||||
1. Gehe zu [GoDaddy Developer Portal](https://developer.godaddy.com/)
|
||||
2. Melde dich mit deinem GoDaddy-Konto an
|
||||
3. Klicke auf **"Keys"** in der Navigation
|
||||
4. Klicke auf **"Create New API Key"**
|
||||
5. Fülle das Formular aus:
|
||||
- **Key Name**: z.B. "Hördle Caddy DNS"
|
||||
- **Environment**: Production (für echte Domains)
|
||||
6. Klicke auf **"Create"**
|
||||
7. **Wichtig**: Kopiere dir den **API Key** und das **API Secret** - das Secret wird nur einmal angezeigt!
|
||||
|
||||
### Alternative: Manuelle DNS-TXT-Records (ohne API)
|
||||
|
||||
Wenn du keine API-Keys verwenden möchtest, kannst du die DNS-TXT-Records manuell setzen. **Hinweis**: Dies ist nur für die initiale Zertifikatsanfrage möglich, nicht für automatische Erneuerungen.
|
||||
|
||||
Siehe Abschnitt "Manuelle DNS-Konfiguration (ohne API)" weiter unten.
|
||||
|
||||
## Schritt 2: Environment-Variablen konfigurieren
|
||||
|
||||
Erstelle eine `.env`-Datei im Projektverzeichnis (oder erweitere die bestehende):
|
||||
|
||||
```bash
|
||||
# GoDaddy API-Credentials für DNS-01 Challenge
|
||||
GODADDY_API_KEY=your_api_key_here
|
||||
GODADDY_API_SECRET=your_api_secret_here
|
||||
|
||||
# Optional: Email für Let's Encrypt Benachrichtigungen
|
||||
CADDY_EMAIL=markus@hoerdle.de
|
||||
```
|
||||
|
||||
**Wichtig**: Die `.env`-Datei sollte nicht in Git committed werden (sollte bereits in `.gitignore` sein).
|
||||
|
||||
## Schritt 3: Docker-Netzwerk erstellen
|
||||
|
||||
Caddy und Hördle müssen im gleichen Docker-Netzwerk kommunizieren:
|
||||
|
||||
```bash
|
||||
# Prüfe, ob das Netzwerk bereits existiert
|
||||
docker network ls | grep hoerdle
|
||||
|
||||
# Falls das Netzwerk bereits existiert, aber falsche Labels hat:
|
||||
# 1. Stoppe alle Container, die das Netzwerk nutzen
|
||||
docker compose -f docker-compose.yml down
|
||||
|
||||
# 2. Lösche das alte Netzwerk (falls keine Container mehr dranhängen)
|
||||
docker network rm hoerdle_default
|
||||
|
||||
# 3. Erstelle das Netzwerk neu
|
||||
docker network create hoerdle_default
|
||||
|
||||
# Falls das Netzwerk nicht existiert, erstelle es:
|
||||
docker network create hoerdle_default
|
||||
```
|
||||
|
||||
**Hinweis**: Die docker-compose.caddy.yml ist so konfiguriert, dass sie das Netzwerk als externes Netzwerk nutzt. Das bedeutet, dass das Netzwerk bereits existieren muss, bevor Caddy gestartet wird.
|
||||
|
||||
## Schritt 4: Caddy starten
|
||||
|
||||
### Option A: Mit docker-compose (Empfohlen)
|
||||
|
||||
```bash
|
||||
# Starte Hördle + Caddy zusammen
|
||||
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||
|
||||
# Nur Caddy starten (wenn Hördle bereits läuft)
|
||||
docker compose -f docker-compose.caddy.yml --profile production up -d
|
||||
```
|
||||
|
||||
### Option B: Nur Caddy starten (Hördle läuft bereits)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.caddy.yml --profile production up -d
|
||||
```
|
||||
|
||||
## Schritt 5: DNS-Konfiguration in GoDaddy
|
||||
|
||||
### Automatisch (mit API-Keys)
|
||||
|
||||
Wenn du API-Keys konfiguriert hast, wird Caddy automatisch die benötigten DNS-TXT-Records erstellen. Keine manuellen DNS-Änderungen nötig!
|
||||
|
||||
### Manuell (ohne API-Keys)
|
||||
|
||||
Wenn du die API-Keys nicht verwenden möchtest, musst du die DNS-TXT-Records manuell setzen:
|
||||
|
||||
#### Für hoerdle.de:
|
||||
|
||||
1. Gehe zu deinem [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/YOUR_DOMAIN/dns)
|
||||
2. Für jedes Wildcard-Zertifikat benötigst du einen TXT-Record:
|
||||
- **Typ**: TXT
|
||||
- **Name**: `_acme-challenge`
|
||||
- **Wert**: (wird von Let's Encrypt generiert - siehe Caddy-Logs)
|
||||
- **TTL**: 600 (10 Minuten)
|
||||
|
||||
**Wichtig**: Für Wildcard-Zertifikate brauchst du:
|
||||
- Einen TXT-Record für `_acme-challenge.hoerdle.de` (Domain selbst)
|
||||
- Einen TXT-Record für `_acme-challenge.*.hoerdle.de` (Wildcard)
|
||||
|
||||
#### Für hördle.de (xn--hrdle-jua.de):
|
||||
|
||||
Das gleiche Vorgehen für die Punycode-Domain:
|
||||
- `_acme-challenge.xn--hrdle-jua.de`
|
||||
- `_acme-challenge.*.xn--hrdle-jua.de`
|
||||
|
||||
**Hinweis**: Die manuelle Methode funktioniert nur für die initiale Zertifikatsanfrage. Für automatische Erneuerungen benötigst du die API-Keys.
|
||||
|
||||
## Schritt 6: Prüfen, ob alles funktioniert
|
||||
|
||||
### Caddy-Logs ansehen
|
||||
|
||||
```bash
|
||||
docker logs -f hoerdle-caddy
|
||||
```
|
||||
|
||||
Du solltest sehen:
|
||||
- Caddy startet erfolgreich
|
||||
- Let's Encrypt-Zertifikate werden angefordert
|
||||
- Zertifikate sind gültig
|
||||
|
||||
### Zertifikate prüfen
|
||||
|
||||
```bash
|
||||
# Prüfe Zertifikate im Browser
|
||||
# Öffne: https://hoerdle.de
|
||||
# Öffne: https://hördle.de
|
||||
```
|
||||
|
||||
Oder via Command-Line:
|
||||
|
||||
```bash
|
||||
# Prüfe Zertifikat für hoerdle.de
|
||||
openssl s_client -connect hoerdle.de:443 -servername hoerdle.de < /dev/null 2>/dev/null | openssl x509 -noout -text | grep "Subject:"
|
||||
|
||||
# Prüfe Zertifikat für hördle.de
|
||||
openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de < /dev/null 2>/dev/null | openssl x509 -noout -text | grep "Subject:"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Caddy startet nicht
|
||||
|
||||
**Problem**: Container stoppt sofort nach Start.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
|
||||
2. Prüfe Caddyfile-Syntax: `docker run --rm -v $(pwd)/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile`
|
||||
3. Prüfe, ob Ports 80/443 frei sind: `sudo netstat -tlnp | grep -E ':80|:443'`
|
||||
|
||||
### Zertifikate werden nicht erstellt
|
||||
|
||||
**Problem**: Let's Encrypt-Zertifikate werden nicht angefordert.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe GoDaddy API-Credentials in `.env`
|
||||
2. Prüfe Caddy-Logs für DNS-Challenge-Fehler
|
||||
3. Stelle sicher, dass die Domains korrekt auf deinen Server zeigen (A-Records)
|
||||
4. Bei manueller DNS-Konfiguration: Prüfe, ob TXT-Records korrekt gesetzt sind
|
||||
|
||||
### DNS-Challenge schlägt fehl
|
||||
|
||||
**Problem**: DNS-01 Challenge kann DNS-Records nicht erstellen.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe GoDaddy API-Permissions
|
||||
2. Stelle sicher, dass API-Keys Production-Keys sind (nicht Development)
|
||||
3. Prüfe Domain-Ownership in GoDaddy
|
||||
4. Warte einige Minuten - DNS-Propagierung kann dauern
|
||||
|
||||
### Audio-Dateien funktionieren nicht
|
||||
|
||||
**Problem**: MP3-Dateien werden nicht korrekt gestreamt.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy | grep -i range`
|
||||
2. Prüfe, ob Range-Header weitergegeben werden (Browser DevTools → Network)
|
||||
3. Stelle sicher, dass der `/uploads/` Handle korrekt konfiguriert ist
|
||||
|
||||
### Container können nicht kommunizieren
|
||||
|
||||
**Problem**: Caddy kann den hoerdle-Container nicht erreichen.
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe, ob beide Container im gleichen Netzwerk sind:
|
||||
```bash
|
||||
docker network inspect hoerdle_default
|
||||
```
|
||||
2. Prüfe, ob hoerdle-Container läuft: `docker ps | grep hoerdle`
|
||||
3. Teste Verbindung von Caddy zu Hördle:
|
||||
```bash
|
||||
docker exec hoerdle-caddy wget -O- http://hoerdle:3000/api/health
|
||||
```
|
||||
**Hinweis**: Der Container-Port ist 3000 (nicht 3010, das ist nur der Host-Port).
|
||||
|
||||
### Netzwerk-Warnung beim Deployment
|
||||
|
||||
**Problem**: Warnung `network hoerdle_default was found but has incorrect label`
|
||||
|
||||
**Erklärung**: Diese Warnung ist **harmlos** und kann ignoriert werden. Docker Compose funktioniert trotzdem einwandfrei. Sie entsteht, wenn das Netzwerk bereits existiert, aber nicht von Docker Compose erstellt wurde.
|
||||
|
||||
**Optional: Warnung beheben** (nur wenn sie stört):
|
||||
```bash
|
||||
# Reparatur-Skript ausführen (stoppt Container kurz)
|
||||
./scripts/fix-network.sh
|
||||
|
||||
# Danach Container neu starten
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Hinweis**: Das Reparatur-Skript stoppt alle Container kurz, die das Netzwerk nutzen. In Produktion sollte dies außerhalb der Hauptnutzungszeit erfolgen.
|
||||
|
||||
## Deployment-Workflow
|
||||
|
||||
### Caddy nur in Produktion aktivieren
|
||||
|
||||
Die `docker-compose.caddy.yml` verwendet das `production`-Profile. Um Caddy zu aktivieren:
|
||||
|
||||
```bash
|
||||
# Mit Production-Profile
|
||||
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||
|
||||
# Ohne Caddy (nur Hördle)
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Caddy aktualisieren
|
||||
|
||||
```bash
|
||||
# Pull neues Caddy-Image
|
||||
docker compose -f docker-compose.caddy.yml pull
|
||||
|
||||
# Restart Caddy-Container
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
```
|
||||
|
||||
### Caddy-Konfiguration ändern
|
||||
|
||||
Nach Änderungen am Caddyfile:
|
||||
|
||||
```bash
|
||||
# Caddyfile validieren
|
||||
docker run --rm -v $(pwd)/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# Caddy neu laden (ohne Downtime)
|
||||
docker compose -f docker-compose.caddy.yml --profile production exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### API-Keys schützen
|
||||
|
||||
- **Niemals** API-Keys in Git committen
|
||||
- Verwende `.env`-Dateien (sollten in `.gitignore` sein)
|
||||
- Setze minimale Berechtigungen für API-Keys in GoDaddy
|
||||
- Rotiere API-Keys regelmäßig
|
||||
|
||||
### Firewall
|
||||
|
||||
Stelle sicher, dass nur Ports 80 und 443 öffentlich erreichbar sind. Port 3010 (Hördle) sollte nicht öffentlich erreichbar sein.
|
||||
|
||||
## Weitere Ressourcen
|
||||
|
||||
- [Caddy Dokumentation](https://caddyserver.com/docs/)
|
||||
- [Caddy DNS-Provider](https://caddyserver.com/docs/modules/tls.dns)
|
||||
- [GoDaddy API Dokumentation](https://developer.godaddy.com/doc/endpoint/domains)
|
||||
- [Let's Encrypt Wildcard-Zertifikate](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge)
|
||||
|
||||
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Caddy Zertifikat-Troubleshooting
|
||||
|
||||
## Problem: Zertifikat für Punycode-Domain (hördle.de / xn--hrdle-jua.de) fehlt
|
||||
|
||||
Wenn die Domain `hördle.de` (xn--hrdle-jua.de) einen `ERR_SSL_PROTOCOL_ERROR` zeigt, bedeutet das, dass kein gültiges SSL-Zertifikat vorhanden ist.
|
||||
|
||||
### Schritt 1: Zertifikat-Status prüfen
|
||||
|
||||
Führe das Check-Script aus:
|
||||
|
||||
```bash
|
||||
./scripts/check-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Dieses Script prüft:
|
||||
- Ob Caddy läuft
|
||||
- Welche Zertifikate vorhanden sind
|
||||
- Ob die DNS-Einträge korrekt sind
|
||||
- Ob die HTTPS-Verbindungen funktionieren
|
||||
|
||||
### Schritt 2: DNS-Einträge prüfen
|
||||
|
||||
**Wichtig**: Beide Domains müssen auf die gleiche Server-IP zeigen!
|
||||
|
||||
#### In GoDaddy prüfen:
|
||||
|
||||
1. Gehe zu [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/hoerdle.de/dns)
|
||||
2. Prüfe die A-Records:
|
||||
|
||||
**Für hoerdle.de:**
|
||||
- Name: `@` oder `hoerdle.de`
|
||||
- Typ: `A`
|
||||
- Wert: `DEINE_SERVER_IP`
|
||||
|
||||
**Für hördle.de (Punycode):**
|
||||
- Name: `@` oder `xn--hrdle-jua.de` (oder der Unicode-Name, falls unterstützt)
|
||||
- Typ: `A`
|
||||
- Wert: **GLEICHE_SERVER_IP wie hoerdle.de**
|
||||
|
||||
#### DNS manuell testen:
|
||||
|
||||
```bash
|
||||
# Prüfe hoerdle.de
|
||||
dig +short hoerdle.de @8.8.8.8
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
dig +short xn--hrdle-jua.de @8.8.8.8
|
||||
|
||||
# Beide sollten die gleiche IP zurückgeben!
|
||||
```
|
||||
|
||||
### Schritt 3: Zertifikat neu erstellen
|
||||
|
||||
Wenn die DNS-Einträge korrekt sind, lösche das alte (fehlgeschlagene) Zertifikat und lass Caddy es neu erstellen:
|
||||
|
||||
```bash
|
||||
./scripts/renew-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Wähle Option 2: "Nur Zertifikat für xn--hrdle-jua.de löschen"
|
||||
|
||||
### Schritt 4: Caddy-Logs überwachen
|
||||
|
||||
Während Caddy das Zertifikat erstellt, überwache die Logs:
|
||||
|
||||
```bash
|
||||
docker logs hoerdle-caddy -f
|
||||
```
|
||||
|
||||
Du solltest sehen:
|
||||
- `[INFO] attempting ACME challenge` - Caddy versucht die Challenge
|
||||
- `[INFO] successfully completed ACME challenge` - Challenge erfolgreich
|
||||
- `[INFO] certificate obtained successfully` - Zertifikat erstellt
|
||||
|
||||
Bei Fehlern siehst du:
|
||||
- `[ERROR] acme: error` - Challenge fehlgeschlagen
|
||||
- `[ERROR] unable to validate` - Validierung fehlgeschlagen
|
||||
|
||||
### Schritt 5: Häufige Probleme und Lösungen
|
||||
|
||||
#### Problem 1: DNS zeigt auf falsche IP
|
||||
|
||||
**Symptom**: `dig` zeigt eine andere IP als erwartet
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe DNS-Einträge in GoDaddy
|
||||
2. Warte auf DNS-Propagierung (kann 5-60 Minuten dauern)
|
||||
3. Verwende einen DNS-Checker: https://www.whatsmydns.net/
|
||||
|
||||
#### Problem 2: Port 80 nicht erreichbar
|
||||
|
||||
**Symptom**: Caddy-Logs zeigen "connection refused" oder Timeout
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Firewall: `sudo ufw status`
|
||||
2. Prüfe ob Port 80 offen ist: `sudo netstat -tulpn | grep :80`
|
||||
3. Prüfe ob Caddy auf Port 80 lauscht: `docker exec hoerdle-caddy netstat -tulpn | grep :80`
|
||||
|
||||
#### Problem 3: Let's Encrypt Rate Limit
|
||||
|
||||
**Symptom**: Logs zeigen "too many certificates already issued"
|
||||
|
||||
**Lösung**:
|
||||
- Warte 1 Woche (Rate Limit von Let's Encrypt)
|
||||
- Oder verwende Staging-Environment zum Testen:
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
#### Problem 4: Punycode-Domain wird nicht erkannt
|
||||
|
||||
**Symptom**: Caddy erstellt Zertifikat nur für hoerdle.de, nicht für xn--hrdle-jua.de
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe ob beide Domains in der Caddyfile stehen
|
||||
2. Prüfe DNS-Einträge (siehe Schritt 2)
|
||||
3. Erzwinge Zertifikat-Erstellung (siehe Schritt 3)
|
||||
|
||||
### Manuelle Zertifikat-Löschung
|
||||
|
||||
Falls das Script nicht funktioniert, kannst du Zertifikate manuell löschen:
|
||||
|
||||
```bash
|
||||
# Alle Zertifikate löschen
|
||||
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/*
|
||||
|
||||
# Nur Punycode-Zertifikat löschen (manuell)
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete
|
||||
|
||||
# Container neu starten
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
```
|
||||
|
||||
### DNS-Propagierung prüfen
|
||||
|
||||
Nach DNS-Änderungen kann es bis zu 60 Minuten dauern, bis alle DNS-Server aktualisiert sind:
|
||||
|
||||
```bash
|
||||
# Prüfe DNS-Propagierung weltweit
|
||||
curl "https://dnschecker.org/#A/hoerdle.de"
|
||||
curl "https://dnschecker.org/#A/xn--hrdle-jua.de"
|
||||
```
|
||||
|
||||
### Test-Zertifikat erstellen (Staging)
|
||||
|
||||
Zum Testen ohne Rate-Limits kannst du ein Staging-Zertifikat erstellen:
|
||||
|
||||
1. Temporär Caddyfile ändern (in beiden Domain-Blocks):
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
2. Container neu starten
|
||||
3. Zertifikat erstellen lassen
|
||||
4. Zurück zu Produktion ändern (Staging-Block entfernen)
|
||||
5. Erneut Container neu starten
|
||||
|
||||
### Verifizieren, dass es funktioniert
|
||||
|
||||
Nach erfolgreicher Zertifikats-Erstellung:
|
||||
|
||||
```bash
|
||||
# Teste HTTPS-Verbindung
|
||||
curl -I https://hoerdle.de
|
||||
curl -I https://xn--hrdle-jua.de
|
||||
|
||||
# Prüfe Zertifikat-Details
|
||||
echo | openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
echo | openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
```
|
||||
|
||||
### Support
|
||||
|
||||
Falls das Problem weiterhin besteht:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
|
||||
2. Prüfe DNS: `dig +short xn--hrdle-jua.de @8.8.8.8`
|
||||
3. Prüfe Firewall: `sudo ufw status`
|
||||
4. Prüfe Port-Zugriff: `curl -I http://hoerdle.de`
|
||||
|
||||
185
docs/DEBUG_VERSION.md
Normal file
185
docs/DEBUG_VERSION.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Debug Version Display - Remote Server Checklist
|
||||
|
||||
## 1. Überprüfe Git-Tags auf dem Remote-Server
|
||||
|
||||
```bash
|
||||
# Im Projekt-Verzeichnis auf dem Remote-Server
|
||||
cd /path/to/hoerdle
|
||||
|
||||
# Zeige alle Tags
|
||||
git tag -l
|
||||
|
||||
# Zeige aktuellen Tag/Version
|
||||
git describe --tags --always
|
||||
|
||||
# Wenn keine Tags angezeigt werden:
|
||||
git fetch --tags
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Sollte `v0.1.0.2` oder ähnlich zeigen
|
||||
|
||||
---
|
||||
|
||||
## 2. Überprüfe die version.txt im Container
|
||||
|
||||
```bash
|
||||
# Zeige den Inhalt der Version-Datei im laufenden Container
|
||||
docker exec hoerdle cat /app/version.txt
|
||||
|
||||
# Sollte die Version zeigen, z.B. "v0.1.0.2"
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Die aktuelle Version, nicht "unknown" oder "dev"
|
||||
|
||||
---
|
||||
|
||||
## 3. Überprüfe die Umgebungsvariable im Container
|
||||
|
||||
```bash
|
||||
# Zeige alle Umgebungsvariablen
|
||||
docker exec hoerdle env | grep APP_VERSION
|
||||
|
||||
# Sollte APP_VERSION=v0.1.0.2 oder ähnlich zeigen
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** `APP_VERSION=v0.1.0.2`
|
||||
|
||||
---
|
||||
|
||||
## 4. Überprüfe die Container-Logs beim Start
|
||||
|
||||
```bash
|
||||
# Zeige die letzten Logs beim Container-Start
|
||||
docker logs hoerdle | head -20
|
||||
|
||||
# Suche speziell nach Version-Ausgaben
|
||||
docker logs hoerdle | grep -i version
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Eine Zeile wie "App version: v0.1.0.2"
|
||||
|
||||
---
|
||||
|
||||
## 5. Teste die API direkt
|
||||
|
||||
```bash
|
||||
# Rufe die Version-API auf
|
||||
curl http://localhost:3010/api/version
|
||||
|
||||
# Sollte JSON zurückgeben: {"version":"v0.1.0.2"}
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** `{"version":"v0.1.0.2"}`
|
||||
|
||||
---
|
||||
|
||||
## 6. Überprüfe wann der Container gebaut wurde
|
||||
|
||||
```bash
|
||||
# Zeige Image-Informationen
|
||||
docker images | grep hoerdle
|
||||
|
||||
# Zeige detaillierte Container-Informationen
|
||||
docker inspect hoerdle | grep -i created
|
||||
```
|
||||
|
||||
**Wichtig:** Wenn das Image vor deinem letzten Deployment erstellt wurde, wurde es noch nicht neu gebaut!
|
||||
|
||||
---
|
||||
|
||||
## 7. Überprüfe Build-Logs
|
||||
|
||||
```bash
|
||||
# Baue das Image neu und beobachte die Ausgabe
|
||||
docker compose build --no-cache 2>&1 | tee build.log
|
||||
|
||||
# Suche nach der Version-Ausgabe im Build
|
||||
grep -i "Building version" build.log
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Eine Zeile wie "Building version: v0.1.0.2"
|
||||
|
||||
---
|
||||
|
||||
## Häufige Probleme und Lösungen
|
||||
|
||||
### Problem 1: Tags nicht auf dem Server
|
||||
```bash
|
||||
git fetch --tags
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
### Problem 2: Container wurde nicht neu gebaut
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Problem 3: Alte version.txt im Container
|
||||
```bash
|
||||
# Stoppe Container, lösche Image, baue neu
|
||||
docker compose down
|
||||
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Problem 4: .git Verzeichnis nicht im Build-Context
|
||||
```bash
|
||||
# Überprüfe ob .git existiert
|
||||
ls -la .git
|
||||
|
||||
# Überprüfe .dockerignore (sollte .git NICHT ausschließen)
|
||||
cat .dockerignore 2>/dev/null || echo "Keine .dockerignore Datei"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vollständiger Neustart (wenn nichts anderes hilft)
|
||||
|
||||
```bash
|
||||
# 1. Stoppe alles
|
||||
docker compose down
|
||||
|
||||
# 2. Lösche alte Images
|
||||
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||
|
||||
# 3. Hole neueste Änderungen und Tags
|
||||
git pull
|
||||
git fetch --tags
|
||||
|
||||
# 4. Überprüfe Version lokal
|
||||
git describe --tags --always
|
||||
|
||||
# 5. Baue komplett neu
|
||||
docker compose build --no-cache
|
||||
|
||||
# 6. Starte Container
|
||||
docker compose up -d
|
||||
|
||||
# 7. Überprüfe Logs
|
||||
docker logs hoerdle | grep -i version
|
||||
|
||||
# 8. Teste API
|
||||
curl http://localhost:3010/api/version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging-Befehl für alle Checks auf einmal
|
||||
|
||||
```bash
|
||||
echo "=== Git Tags ===" && \
|
||||
git describe --tags --always && \
|
||||
echo -e "\n=== version.txt im Container ===" && \
|
||||
docker exec hoerdle cat /app/version.txt 2>/dev/null || echo "Container läuft nicht oder Datei fehlt" && \
|
||||
echo -e "\n=== APP_VERSION Env ===" && \
|
||||
docker exec hoerdle env | grep APP_VERSION || echo "Variable nicht gesetzt" && \
|
||||
echo -e "\n=== API Response ===" && \
|
||||
curl -s http://localhost:3010/api/version && \
|
||||
echo -e "\n\n=== Container Created ===" && \
|
||||
docker inspect hoerdle | grep -i created | head -1
|
||||
```
|
||||
|
||||
Kopiere diesen Befehl und führe ihn auf dem Remote-Server aus. Schicke mir die Ausgabe!
|
||||
116
docs/DEPLOYMENT.md
Normal file
116
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Automated Deployment
|
||||
|
||||
Use the deployment script for zero-downtime deployments:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Create a database backup
|
||||
2. Pull latest changes from git
|
||||
3. Fetch all git tags (for version display)
|
||||
4. Build the new Docker image
|
||||
5. Restart the container with minimal downtime
|
||||
6. Clean up old images
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
If you need to deploy manually:
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Fetch tags (important for version display!)
|
||||
git fetch --tags
|
||||
|
||||
# Build and restart
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Version Display
|
||||
|
||||
The app displays the current version in the footer. The version is determined as follows:
|
||||
|
||||
1. **During Docker build**: The version is extracted from git tags using `git describe --tags --always`
|
||||
2. **At runtime**: The version is read from `/app/version.txt` and exposed via the `/api/version` endpoint
|
||||
3. **Local development**: The version is extracted directly from git on each request
|
||||
|
||||
### Building with a specific version
|
||||
|
||||
You can override the version during build:
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg APP_VERSION=v1.2.3
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If the version shows as "dev" or "unknown":
|
||||
|
||||
1. Make sure git tags are pushed to the remote repository:
|
||||
```bash
|
||||
git push --tags
|
||||
```
|
||||
|
||||
2. On the deployment server, fetch the tags:
|
||||
```bash
|
||||
git fetch --tags
|
||||
```
|
||||
|
||||
3. Verify tags are available:
|
||||
```bash
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
4. Rebuild the Docker image:
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The container includes a health check that monitors the `/api/daily` endpoint. Check the health status:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
|
||||
Look for the "healthy" status in the STATUS column.
|
||||
|
||||
## Caddy Reverse Proxy (Optional - Production)
|
||||
|
||||
For production deployments with automatic SSL/TLS certificates, Caddy can be used as a reverse proxy. Caddy provides:
|
||||
|
||||
- Automatic Let's Encrypt certificates (including wildcard certificates)
|
||||
- HTTP to HTTPS redirect
|
||||
- Optimized settings for audio streaming and file uploads
|
||||
- Support for both `hoerdle.de` and `hördle.de` (Punycode: `xn--hrdle-jua.de`)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Follow the setup guide**: See `CADDY_SETUP.md` for detailed instructions
|
||||
2. **Configure environment variables**: Add GoDaddy API credentials to your `.env` file
|
||||
3. **Start with Caddy**:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||
```
|
||||
|
||||
### Without Caddy
|
||||
|
||||
If you don't want to use Caddy, you can deploy normally:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
The application will still be accessible on port 3010, but you'll need to configure SSL/TLS separately (e.g., with nginx).
|
||||
|
||||
### Caddy Troubleshooting
|
||||
|
||||
See `CADDY_SETUP.md` for detailed troubleshooting information.
|
||||
106
docs/DOCKER_BUILD_FIX.md
Normal file
106
docs/DOCKER_BUILD_FIX.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Docker Build Fix: Upload-Dateien ausschließen
|
||||
|
||||
## Problem
|
||||
|
||||
Der Docker Build schlug fehl mit:
|
||||
```
|
||||
no space left on device
|
||||
```
|
||||
|
||||
**Ursache**: Die großen MP3-Dateien in `public/uploads/` wurden in den Build-Context kopiert und verbrauchten zu viel Speicherplatz.
|
||||
|
||||
## Lösung
|
||||
|
||||
Eine `.dockerignore` Datei wurde erstellt, die folgende Dateien/Ordner vom Build ausschließt:
|
||||
|
||||
- `public/uploads/*` - Upload-Dateien (werden als Volume gemountet)
|
||||
- `data/*` - Datenbank-Dateien (werden als Volume gemountet)
|
||||
- `node_modules` - werden während des Builds installiert
|
||||
- `.next`, `out`, `build` - Build-Artefakte
|
||||
- Backup-Dateien, Logs, temporäre Dateien
|
||||
|
||||
## Zusätzliche Maßnahmen
|
||||
|
||||
Falls der Build weiterhin Probleme macht:
|
||||
|
||||
### 1. Docker aufräumen
|
||||
|
||||
```bash
|
||||
# Entferne nicht verwendete Images
|
||||
docker image prune -a
|
||||
|
||||
# Entferne nicht verwendete Container
|
||||
docker container prune
|
||||
|
||||
# Entferne nicht verwendete Volumes (VORSICHT: kann Daten löschen!)
|
||||
docker volume prune
|
||||
|
||||
# Kompletter Cleanup (alles außer laufenden Containern)
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
### 2. Speicherplatz prüfen
|
||||
|
||||
```bash
|
||||
# Zeige Speicherplatz
|
||||
df -h
|
||||
|
||||
# Zeige Docker-Speicherverbrauch
|
||||
docker system df
|
||||
|
||||
# Zeige größte Images
|
||||
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | sort -k3 -h
|
||||
```
|
||||
|
||||
### 3. Build-Kontext prüfen
|
||||
|
||||
```bash
|
||||
# Prüfe was in den Build-Context kopiert wird
|
||||
docker build --no-cache --progress=plain -t test-build . 2>&1 | grep "transferring context"
|
||||
```
|
||||
|
||||
### 4. Upload-Dateien manuell ausschließen
|
||||
|
||||
Falls die `.dockerignore` nicht greift, können Upload-Dateien vorübergehend verschoben werden:
|
||||
|
||||
```bash
|
||||
# Vor dem Build
|
||||
mv public/uploads public/uploads.backup
|
||||
mkdir -p public/uploads
|
||||
touch public/uploads/.gitkeep
|
||||
|
||||
# Build durchführen
|
||||
docker compose build
|
||||
|
||||
# Uploads wiederherstellen
|
||||
rm -rf public/uploads
|
||||
mv public/uploads.backup public/uploads
|
||||
```
|
||||
|
||||
## Wichtig
|
||||
|
||||
Die Upload-Dateien werden **nicht** ins Docker-Image kopiert, sondern als Volume gemountet:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
```
|
||||
|
||||
Das bedeutet:
|
||||
- Upload-Dateien bleiben auf dem Host-System
|
||||
- Sie werden zur Laufzeit gemountet
|
||||
- Sie sollten **nicht** ins Image kopiert werden (spart viel Speicher)
|
||||
|
||||
## Verifikation
|
||||
|
||||
Nach dem Build sollte das Image deutlich kleiner sein:
|
||||
|
||||
```bash
|
||||
# Zeige Image-Größe
|
||||
docker images hoerdle-hoerdle
|
||||
|
||||
# Prüfe ob Uploads im Image sind
|
||||
docker run --rm hoerdle-hoerdle ls -lh /app/public/uploads
|
||||
# Sollte nur .gitkeep oder Covers zeigen, keine MP3-Dateien
|
||||
```
|
||||
|
||||
83
docs/FIX_I18N.md
Normal file
83
docs/FIX_I18N.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Fix für i18n-Daten (String → JSON Konvertierung)
|
||||
|
||||
## Problem
|
||||
Die Datenbank hat Genre-/Special-/News-Namen als einfache Strings (`"Rock"`) statt JSON (`{"de": "Rock", "en": "Rock"}`) gespeichert, was zu `SyntaxError: "Rock" is not valid JSON` führt.
|
||||
|
||||
## Lösung: Manuell ausführen
|
||||
|
||||
Führe diese Befehle **direkt auf dem Server** aus:
|
||||
|
||||
```bash
|
||||
cd ~/hoerdle
|
||||
|
||||
# 1. Backup erstellen
|
||||
docker cp hoerdle:/app/data/prod.db ./data/prod.db.backup.$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# 2. Kopiere DB lokal
|
||||
docker cp hoerdle:/app/data/prod.db ./data/prod.db.tmp
|
||||
|
||||
# 3. Setze Berechtigungen
|
||||
sudo chmod 666 ./data/prod.db.tmp
|
||||
sudo chmod 775 ./data
|
||||
|
||||
# 4. Prüfe ob sqlite3 installiert ist
|
||||
which sqlite3 || sudo apt-get install -y sqlite3
|
||||
|
||||
# 5. Fixe die Datenbank (kopiere diesen Block komplett)
|
||||
sqlite3 ./data/prod.db.tmp << 'EOF'
|
||||
UPDATE Genre SET name = json_object('de', name, 'en', name) WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||
UPDATE Genre SET subtitle = json_object('de', subtitle, 'en', subtitle) WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||
UPDATE Special SET name = json_object('de', name, 'en', name) WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||
UPDATE Special SET subtitle = json_object('de', subtitle, 'en', subtitle) WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||
UPDATE News SET title = json_object('de', title, 'en', title) WHERE typeof(title) = 'text' AND title NOT LIKE '{%';
|
||||
UPDATE News SET content = json_object('de', content, 'en', content) WHERE typeof(content) = 'text' AND content NOT LIKE '{%';
|
||||
SELECT '✅ Fertig!' as status;
|
||||
EOF
|
||||
|
||||
# 6. Kopiere zurück
|
||||
docker cp ./data/prod.db.tmp hoerdle:/app/data/prod.db
|
||||
|
||||
# 7. Aufräumen
|
||||
rm ./data/prod.db.tmp
|
||||
|
||||
# 8. Container neu starten
|
||||
docker compose restart hoerdle
|
||||
|
||||
# 9. Logs prüfen
|
||||
docker logs hoerdle --tail=50
|
||||
```
|
||||
|
||||
Falls Schritt 5 mit "permission denied" fehlschlägt, verwende `sudo`:
|
||||
|
||||
```bash
|
||||
sudo sqlite3 ./data/prod.db.tmp << 'EOF'
|
||||
[... SQL-Befehle wie oben ...]
|
||||
EOF
|
||||
```
|
||||
|
||||
## Automatisiertes Skript
|
||||
|
||||
Alternativ kannst du das automatische Skript verwenden:
|
||||
|
||||
```bash
|
||||
./scripts/fix-i18n-easy.sh
|
||||
```
|
||||
|
||||
Oder das lokale Skript:
|
||||
|
||||
```bash
|
||||
./scripts/fix-i18n-local.sh
|
||||
```
|
||||
|
||||
## Prüfen ob es funktioniert hat
|
||||
|
||||
Nach dem Neustart sollte die Seite wieder funktionieren:
|
||||
|
||||
```bash
|
||||
# Prüfe Logs (sollte keine JSON-Fehler mehr zeigen)
|
||||
docker logs hoerdle --tail=100 | grep -i "json\|error" || echo "✅ Keine JSON-Fehler gefunden"
|
||||
|
||||
# Teste die Seite
|
||||
curl -s https://hoerdle.de/de | head -20
|
||||
```
|
||||
|
||||
349
docs/I18N.md
Normal file
349
docs/I18N.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Internationalisierung (i18n) Dokumentation
|
||||
|
||||
Hördle unterstützt vollständige Mehrsprachigkeit (Internationalisierung) für Deutsch und Englisch.
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die i18n-Implementierung basiert auf [next-intl](https://next-intl-docs.vercel.app/) und nutzt den Next.js App Router mit dynamischen `[locale]`-Segmenten.
|
||||
|
||||
## Unterstützte Sprachen
|
||||
|
||||
- **Englisch (en)** - Standardsprache
|
||||
- **Deutsch (de)**
|
||||
|
||||
## URL-Struktur
|
||||
|
||||
Alle Routen sind lokalisiert:
|
||||
|
||||
- `http://localhost:3000/` → Redirect zu `/en` (Standard)
|
||||
- `http://localhost:3000/de` → Deutsche Version
|
||||
- `http://localhost:3000/en` → Englische Version
|
||||
- `http://localhost:3000/de/admin` → Admin-Dashboard (Deutsch)
|
||||
- `http://localhost:3000/de/Rock` → Rock Genre (Deutsch)
|
||||
- `http://localhost:3000/de/special/Weihnachtslieder` → Special (Deutsch)
|
||||
|
||||
## Architektur
|
||||
|
||||
### Verzeichnisstruktur
|
||||
|
||||
```
|
||||
app/
|
||||
[locale]/ # Lokalisierte Routen
|
||||
layout.tsx # Root Layout mit i18n Provider
|
||||
page.tsx # Homepage
|
||||
admin/
|
||||
page.tsx # Admin Dashboard
|
||||
[genre]/
|
||||
page.tsx # Genre-spezifische Seite
|
||||
special/
|
||||
[name]/
|
||||
page.tsx # Special-Seite
|
||||
|
||||
i18n/
|
||||
request.ts # next-intl Konfiguration
|
||||
|
||||
messages/
|
||||
de.json # Deutsche Übersetzungen
|
||||
en.json # Englische Übersetzungen
|
||||
|
||||
lib/
|
||||
i18n.ts # Helper-Funktionen für lokalisierte DB-Werte
|
||||
navigation.ts # Lokalisierte Navigation (Link, useRouter, etc.)
|
||||
```
|
||||
|
||||
### Übersetzungsdateien
|
||||
|
||||
Die Übersetzungen sind in JSON-Dateien unter `messages/` organisiert:
|
||||
|
||||
```json
|
||||
{
|
||||
"Common": {
|
||||
"loading": "Laden...",
|
||||
"error": "Ein Fehler ist aufgetreten"
|
||||
},
|
||||
"Game": {
|
||||
"play": "Abspielen",
|
||||
"pause": "Pause",
|
||||
"won": "Gewonnen!"
|
||||
},
|
||||
"Home": {
|
||||
"welcome": "Willkommen bei Hördle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Datenbank-Schema
|
||||
|
||||
Die folgenden Modelle unterstützen mehrsprachige Felder:
|
||||
|
||||
#### Genre
|
||||
- `name`: JSON `{ "de": "Rock", "en": "Rock" }`
|
||||
- `subtitle`: JSON `{ "de": "Klassischer Rock", "en": "Classic Rock" }`
|
||||
|
||||
#### Special
|
||||
- `name`: JSON `{ "de": "Weihnachtslieder", "en": "Christmas Songs" }`
|
||||
- `subtitle`: JSON `{ "de": "Festliche Musik", "en": "Festive Music" }`
|
||||
|
||||
#### News
|
||||
- `title`: JSON `{ "de": "Neues Feature", "en": "New Feature" }`
|
||||
- `content`: JSON `{ "de": "Markdown Inhalt...", "en": "Markdown content..." }`
|
||||
|
||||
### Helper-Funktionen
|
||||
|
||||
#### `getLocalizedValue(value, locale, fallback?)`
|
||||
|
||||
Extrahiert den lokalisierten Wert aus einem JSON-Objekt:
|
||||
|
||||
```typescript
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const genreName = getLocalizedValue(genre.name, 'de'); // "Rock"
|
||||
const genreNameEn = getLocalizedValue(genre.name, 'en'); // "Rock"
|
||||
```
|
||||
|
||||
**Fallback-Verhalten:**
|
||||
1. Versucht die angeforderte Locale (`de` oder `en`)
|
||||
2. Fallback zu `en` falls nicht vorhanden
|
||||
3. Fallback zu `de` falls nicht vorhanden
|
||||
4. Fallback zum ersten verfügbaren Schlüssel
|
||||
5. Fallback zum übergebenen `fallback`-Parameter
|
||||
|
||||
#### `createLocalizedObject(de, en?)`
|
||||
|
||||
Erstellt ein lokalisiertes Objekt:
|
||||
|
||||
```typescript
|
||||
import { createLocalizedObject } from '@/lib/i18n';
|
||||
|
||||
const name = createLocalizedObject('Rock', 'Rock');
|
||||
// { de: "Rock", en: "Rock" }
|
||||
```
|
||||
|
||||
## Verwendung in Komponenten
|
||||
|
||||
### Server Components
|
||||
|
||||
```typescript
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
export default async function Page({ params }: { params: { locale: string } }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
|
||||
const genreName = getLocalizedValue(genre.name, locale);
|
||||
|
||||
return <h1>{t('welcome')}</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
### Client Components
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
export default function Game() {
|
||||
const t = useTranslations('Game');
|
||||
const locale = useLocale();
|
||||
|
||||
return <button>{t('play')}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
Verwende die lokalisierte Navigation aus `lib/navigation.ts`:
|
||||
|
||||
```typescript
|
||||
import { Link } from '@/lib/navigation';
|
||||
|
||||
// Automatisch lokalisiert
|
||||
<Link href="/admin">Admin</Link>
|
||||
<Link href="/Rock">Rock</Link>
|
||||
```
|
||||
|
||||
## Admin-Interface
|
||||
|
||||
Das Admin-Dashboard unterstützt mehrsprachige Eingaben:
|
||||
|
||||
1. **Sprach-Tabs:** Wechsle zwischen `DE` und `EN` Tabs
|
||||
2. **Genre/Special/News:** Alle Felder können in beiden Sprachen bearbeitet werden
|
||||
3. **Vorschau:** Sieh dir die lokalisierte Version direkt an
|
||||
|
||||
### Beispiel: Genre erstellen
|
||||
|
||||
1. Öffne `/de/admin`
|
||||
2. Wähle den `DE` Tab
|
||||
3. Gib Name und Subtitle ein
|
||||
4. Wechsle zum `EN` Tab
|
||||
5. Gib die englischen Übersetzungen ein
|
||||
6. Speichere
|
||||
|
||||
## Migration bestehender Daten
|
||||
|
||||
Bestehende Daten werden automatisch migriert:
|
||||
|
||||
1. **Migration `20251128131405_add_i18n_columns`:** Fügt neue JSON-Spalten hinzu
|
||||
2. **Migration `20251128132806_switch_to_json_columns`:** Konvertiert String-Spalten zu JSON
|
||||
|
||||
**Wichtig:** Alte String-Werte werden automatisch in beide Sprachen kopiert:
|
||||
- `"Rock"` → `{ "de": "Rock", "en": "Rock" }`
|
||||
|
||||
## Proxy
|
||||
|
||||
Der Proxy (`proxy.ts`) leitet Anfragen automatisch um:
|
||||
|
||||
- `/` → `/en` (Standard)
|
||||
- Ungültige Locales → 404
|
||||
- Validiert Locale-Parameter
|
||||
|
||||
## Sprachumschalter
|
||||
|
||||
Die `LanguageSwitcher`-Komponente ermöglicht Nutzern, zwischen Sprachen zu wechseln:
|
||||
|
||||
```typescript
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
|
||||
<LanguageSwitcher />
|
||||
```
|
||||
|
||||
Die aktuelle Route bleibt erhalten, nur die Locale ändert sich:
|
||||
- `/de/admin` → `/en/admin`
|
||||
- `/de/Rock` → `/en/Rock`
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
API-Routen unterstützen einen optionalen `locale`-Parameter:
|
||||
|
||||
```typescript
|
||||
GET /api/genres?locale=de
|
||||
GET /api/specials?locale=en
|
||||
GET /api/news?locale=de
|
||||
```
|
||||
|
||||
Falls kein `locale` angegeben wird, wird `en` als Standard verwendet.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Immer `getLocalizedValue` verwenden
|
||||
|
||||
❌ **Falsch:**
|
||||
```typescript
|
||||
<span>{genre.name}</span> // Rendert { de: "...", en: "..." }
|
||||
```
|
||||
|
||||
✅ **Richtig:**
|
||||
```typescript
|
||||
<span>{getLocalizedValue(genre.name, locale)}</span>
|
||||
```
|
||||
|
||||
### 2. Übersetzungsschlüssel konsistent benennen
|
||||
|
||||
Verwende Namespaces für bessere Organisation:
|
||||
- `Common.*` - Allgemeine UI-Elemente
|
||||
- `Game.*` - Spiel-spezifische Texte
|
||||
- `Home.*` - Homepage-Texte
|
||||
- `Navigation.*` - Navigations-Elemente
|
||||
|
||||
### 3. Fallbacks definieren
|
||||
|
||||
Immer einen Fallback-Wert angeben:
|
||||
|
||||
```typescript
|
||||
const name = getLocalizedValue(genre.name, locale, 'Unbekannt');
|
||||
```
|
||||
|
||||
### 4. Neue Übersetzungen hinzufügen
|
||||
|
||||
1. Füge den Schlüssel zu `messages/de.json` hinzu
|
||||
2. Füge den Schlüssel zu `messages/en.json` hinzu
|
||||
3. Verwende `useTranslations('Namespace')` oder `getTranslations('Namespace')`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 404-Fehler auf `/de` oder `/en`
|
||||
|
||||
**Problem:** Route wird nicht gefunden.
|
||||
|
||||
**Lösung:**
|
||||
1. Überprüfe, ob `proxy.ts` korrekt konfiguriert ist
|
||||
2. Stelle sicher, dass `app/[locale]/layout.tsx` existiert
|
||||
3. Prüfe die `i18n/request.ts` Konfiguration
|
||||
|
||||
### "Objects are not valid as a React child"
|
||||
|
||||
**Problem:** Ein JSON-Objekt wird direkt gerendert statt des lokalisierten Werts.
|
||||
|
||||
**Lösung:**
|
||||
Verwende `getLocalizedValue()`:
|
||||
|
||||
```typescript
|
||||
// ❌ Falsch
|
||||
<span>{genre.name}</span>
|
||||
|
||||
// ✅ Richtig
|
||||
<span>{getLocalizedValue(genre.name, locale)}</span>
|
||||
```
|
||||
|
||||
### Übersetzungen werden nicht angezeigt
|
||||
|
||||
**Problem:** Texte erscheinen als Schlüssel (z.B. `"Game.play"`).
|
||||
|
||||
**Lösung:**
|
||||
1. Überprüfe, ob der Übersetzungsschlüssel in `messages/de.json` und `messages/en.json` existiert
|
||||
2. Stelle sicher, dass der Namespace korrekt ist: `useTranslations('Game')` für `Game.play`
|
||||
3. Prüfe die JSON-Syntax auf Fehler
|
||||
|
||||
### Admin-Interface zeigt Objekte statt Text
|
||||
|
||||
**Problem:** In Dropdowns oder Listen werden `{ de: "...", en: "..." }` angezeigt.
|
||||
|
||||
**Lösung:**
|
||||
Verwende `getLocalizedValue()` in allen Render-Funktionen:
|
||||
|
||||
```typescript
|
||||
// ❌ Falsch
|
||||
<option value={s.id}>{s.name}</option>
|
||||
|
||||
// ✅ Richtig
|
||||
<option value={s.id}>{getLocalizedValue(s.name, activeTab)}</option>
|
||||
```
|
||||
|
||||
## Erweiterung um weitere Sprachen
|
||||
|
||||
Um eine neue Sprache hinzuzufügen (z.B. Französisch):
|
||||
|
||||
1. **Übersetzungsdatei erstellen:**
|
||||
```bash
|
||||
cp messages/de.json messages/fr.json
|
||||
```
|
||||
|
||||
2. **Übersetzungen hinzufügen:**
|
||||
Bearbeite `messages/fr.json` mit französischen Übersetzungen
|
||||
|
||||
3. **Locale zur Konfiguration hinzufügen:**
|
||||
- `i18n/request.ts`: `const locales = ['en', 'de', 'fr'];`
|
||||
- `proxy.ts`: `locales: ['en', 'de', 'fr']`
|
||||
- `lib/navigation.ts`: `export const locales = ['de', 'en', 'fr'] as const;`
|
||||
|
||||
4. **Layout aktualisieren:**
|
||||
```typescript
|
||||
// app/[locale]/layout.tsx
|
||||
if (!['en', 'de', 'fr'].includes(locale)) {
|
||||
notFound();
|
||||
}
|
||||
```
|
||||
|
||||
5. **LanguageSwitcher erweitern:**
|
||||
Füge einen Button für `fr` hinzu
|
||||
|
||||
6. **Datenbank-Migration:**
|
||||
Bestehende Daten behalten ihre Struktur, neue Einträge können optional `fr` enthalten
|
||||
|
||||
## Weitere Ressourcen
|
||||
|
||||
- [next-intl Dokumentation](https://next-intl-docs.vercel.app/)
|
||||
- [Next.js App Router i18n](https://nextjs.org/docs/app/building-your-application/routing/internationalization)
|
||||
|
||||
167
docs/PLAUSIBLE_SETUP.md
Normal file
167
docs/PLAUSIBLE_SETUP.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Plausible Analytics Konfiguration
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die App verwendet Plausible Analytics für anonyme Nutzungsstatistiken. Die Konfiguration erfolgt über Umgebungsvariablen.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Erforderliche Variablen
|
||||
|
||||
**Nur eine Variable ist erforderlich:**
|
||||
|
||||
1. **`NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`** (erforderlich)
|
||||
- Die vollständige URL zum Plausible-Script
|
||||
- Beispiel (selbst gehostet): `https://plausible.elpatron.me/js/script.js`
|
||||
- Beispiel (extern): `https://plausible.io/js/script.js`
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||
|
||||
### Konfiguration für Docker
|
||||
|
||||
Da es sich um **Build-Time Variablen** handelt (NEXT_PUBLIC_*), muss die App neu gebaut werden, wenn diese geändert werden.
|
||||
|
||||
#### Schritt 1: Umgebungsvariablen setzen
|
||||
|
||||
Erstelle oder bearbeite eine `.env`-Datei im Projektverzeichnis:
|
||||
|
||||
```bash
|
||||
# Plausible Analytics (Script-URL ist erforderlich)
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||
|
||||
# Die Domain wird automatisch erkannt - keine weitere Konfiguration nötig!
|
||||
```
|
||||
|
||||
#### Schritt 2: docker-compose.yml konfigurieren
|
||||
|
||||
Stelle sicher, dass die Variablen als Build-Args übergeben werden:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
hoerdle:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||
```
|
||||
|
||||
Die `docker-compose.example.yml` enthält bereits diese Konfiguration.
|
||||
|
||||
#### Schritt 3: App neu bauen
|
||||
|
||||
**WICHTIG:** Nach Änderung der Plausible-Variablen muss die App neu gebaut werden:
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Oder mit dem Deploy-Script:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
### Konfiguration für beide Domains
|
||||
|
||||
Die App unterstützt **automatisches Tracking** für beide Domains (`hoerdle.de` und `hördle.de`). Die Domain wird automatisch aus dem Request-Header ausgelesen und entsprechend in Plausible getrackt.
|
||||
|
||||
#### Automatisches Domain-Tracking
|
||||
|
||||
**Standard-Verhalten:** Die App erkennt automatisch, welche Domain aufgerufen wurde, und setzt die entsprechende `data-domain` im Plausible-Script:
|
||||
- `https://hoerdle.de/*` → `data-domain="hoerdle.de"`
|
||||
- `https://hördle.de/*` → `data-domain="hördle.de"`
|
||||
|
||||
#### In Plausible konfigurieren
|
||||
|
||||
Du hast zwei Optionen:
|
||||
|
||||
##### Option 1: Beide Domains als separate Sites (separate Statistiken) - Empfohlen für getrenntes Tracking
|
||||
|
||||
1. Erstelle in Plausible zwei separate Sites:
|
||||
- `hoerdle.de`
|
||||
- `hördle.de`
|
||||
|
||||
2. Fertig! Die App trackt automatisch die richtige Domain.
|
||||
|
||||
**Vorteil:** Separate Statistiken für jede Domain.
|
||||
|
||||
##### Option 2: Beide Domains als Aliase für eine Site (gemeinsame Statistiken)
|
||||
|
||||
1. Erstelle in Plausible eine Site: `hoerdle.de`
|
||||
2. Füge `hördle.de` als Alias hinzu (in den Site-Einstellungen)
|
||||
|
||||
3. Fertig! Die App trackt automatisch die richtige Domain, und Plausible behandelt beide als Aliase für die gleiche Site.
|
||||
|
||||
**Hinweis:** Du musst nichts zusätzlich konfigurieren. Die App trackt automatisch `hoerdle.de` oder `hördle.de` basierend auf der Request-Domain, und Plausible erkennt beide als Aliase.
|
||||
|
||||
**Vorteil:** Gemeinsame Statistiken für beide Domains in einer Site.
|
||||
|
||||
#### Empfehlung
|
||||
|
||||
Für separate Statistiken: **Option 1** (automatisches Tracking)
|
||||
Für gemeinsame Statistiken: **Option 2** (Aliase in Plausible)
|
||||
|
||||
### Automatische CSP-Anpassung
|
||||
|
||||
Die Content Security Policy (CSP) in `proxy.ts` wird automatisch an die konfigurierte Plausible-URL angepasst. Die Domain wird automatisch aus der Script-URL extrahiert.
|
||||
|
||||
### Prüfen der Konfiguration
|
||||
|
||||
Nach dem Neubau kannst du prüfen, ob Plausible korrekt geladen wird:
|
||||
|
||||
1. **Browser-Entwicklertools öffnen**
|
||||
- Network-Tab: Suche nach dem Plausible-Script
|
||||
- Console: Prüfe auf Fehler
|
||||
|
||||
2. **Prüfe die Meta-Tags**
|
||||
```html
|
||||
<script defer data-domain="hoerdle.de" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||
```
|
||||
|
||||
3. **Prüfe Plausible-Dashboard**
|
||||
- Öffne dein Plausible-Dashboard
|
||||
- Prüfe, ob Daten ankommen
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Plausible wird nicht geladen
|
||||
|
||||
- Prüfe, ob die Umgebungsvariablen korrekt gesetzt sind
|
||||
- Prüfe, ob die App neu gebaut wurde (Build-Time Variablen!)
|
||||
- Prüfe Browser-Console auf CSP-Fehler
|
||||
|
||||
#### CSP blockiert Plausible
|
||||
|
||||
Die CSP sollte automatisch angepasst werden. Falls Probleme auftreten:
|
||||
- Prüfe, ob `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` korrekt gesetzt ist
|
||||
- Prüfe die Logs des Containers
|
||||
|
||||
#### Daten werden nicht in Plausible angezeigt
|
||||
|
||||
- Prüfe, ob die Domain in Plausible als Site konfiguriert ist
|
||||
- Prüfe, ob `data-domain` Attribut mit der konfigurierten Domain übereinstimmt
|
||||
- Prüfe Browser-Console auf Fehler beim Laden des Scripts
|
||||
|
||||
### Beispiel-Konfiguration
|
||||
|
||||
#### Für selbst gehostetes Plausible:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||
```
|
||||
|
||||
#### Für Plausible.io (extern):
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.io/js/script.js
|
||||
```
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt - keine weitere Konfiguration nötig!
|
||||
|
||||
### Weitere Informationen
|
||||
|
||||
- [Plausible Dokumentation](https://plausible.io/docs)
|
||||
- [Plausible Self-Hosting](https://plausible.io/docs/self-hosting)
|
||||
|
||||
293
docs/SCORING_OPTIONS.md
Normal file
293
docs/SCORING_OPTIONS.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Scoring-System Optionen
|
||||
|
||||
## Problem-Analyse
|
||||
|
||||
### Aktuelle Situation
|
||||
- **Start:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||
- **Skip:** -5 Punkte
|
||||
- **Replay:** -1 Punkt
|
||||
|
||||
### Problem (vor der Änderung)
|
||||
Bei vielen Versuchen kam man mit einem relativ hohen Score heraus:
|
||||
- Beispiel (alt): 7 Versuche = 90 + 20 - (6 × 3) = **92 Punkte**
|
||||
|
||||
### Lösung (aktuell implementiert)
|
||||
Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die Track-Verlängerung (unlockSteps) abgezogen:
|
||||
- Beispiel (neu): 7 Versuche = 90 + 20 - (6 × 8) = **62 Punkte**
|
||||
- Start: 90 Punkte
|
||||
- 6 falsche Versuche: -48 Punkte (6 × -8, bestehend aus -3 für falsch + -5 für Verlängerung)
|
||||
- 1 richtiger Versuch: +20 Punkte
|
||||
- **Ergebnis: 62 Punkte**
|
||||
|
||||
Dies spiegelt nun besser die tatsächliche Leistung wider. Das System bleibt motivierend, da richtige Antworten weiterhin belohnt werden.
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Progressive Abzüge ⚠️ (Intransparent)
|
||||
|
||||
### Konzept
|
||||
Abzüge steigen mit jedem Versuch, aber das System ist schwer nachvollziehbar.
|
||||
|
||||
```
|
||||
- Versuch 1-2: -2 Punkte pro falscher Antwort
|
||||
- Versuch 3-4: -4 Punkte pro falscher Antwort
|
||||
- Versuch 5-6: -6 Punkte pro falscher Antwort
|
||||
- Versuch 7: -8 Punkte
|
||||
```
|
||||
|
||||
### Beispiel
|
||||
Bei 7 Versuchen: 90 + 20 - (2+2+4+4+6+6) = **86 Punkte**
|
||||
|
||||
### Probleme
|
||||
- **Intransparent**: Spieler müssen sich merken, welche Abzüge in welcher Runde gelten
|
||||
- **Schwer erklärbar**: Das Regelwerk ist komplex
|
||||
- **Unklar im UI**: Aktuelle Abzüge sind nicht sofort ersichtlich
|
||||
|
||||
### Vorteile
|
||||
- Progressive Bestrafung für viele Versuche
|
||||
- Fairer als aktuelles System
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Bonus-Malus-System
|
||||
|
||||
### Konzept
|
||||
Höhere Belohnungen für frühe Erfolge + progressive Abzüge.
|
||||
|
||||
```
|
||||
Start: 90 Punkte
|
||||
|
||||
Richtige Antwort (Bonus abhängig vom Versuch):
|
||||
- Versuch 1: +30 Punkte (sehr gut!)
|
||||
- Versuch 2: +25 Punkte (gut!)
|
||||
- Versuch 3: +20 Punkte (okay)
|
||||
- Versuch 4: +15 Punkte
|
||||
- Versuch 5+: +10 Punkte
|
||||
|
||||
Falsche Antwort (progressive Abzüge):
|
||||
- Versuch 1-2: -3 Punkte
|
||||
- Versuch 3-4: -5 Punkte
|
||||
- Versuch 5-6: -8 Punkte
|
||||
- Versuch 7: -10 Punkte
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: 90 + 30 = **120 Punkte** ⭐
|
||||
- Gelöst in Versuch 4 (nach 3 Fehlern): 90 + 15 - (3+5+5) = **92 Punkte**
|
||||
- Gelöst in Versuch 7 (nach 6 Fehlern): 90 + 10 - (3+5+5+8+8+10) = **61 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Transparent**: Klare Regeln pro Versuch
|
||||
- **Motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||
- **Fair**: Späte Erfolge werden abgewertet
|
||||
|
||||
### Nachteile
|
||||
- Etwas komplexer als aktuelles System
|
||||
- Muss im UI klar kommuniziert werden
|
||||
|
||||
---
|
||||
|
||||
## Option 3: Effizienz-Multiplikator
|
||||
|
||||
### Konzept
|
||||
Basis-System bleibt, aber Multiplikator basierend auf Versuchszahl.
|
||||
|
||||
```
|
||||
Basis-System (wie aktuell, aber mit höheren Abzügen):
|
||||
- Falsche Antwort: -5 Punkte (statt -3)
|
||||
- Skip: -7 Punkte (statt -5)
|
||||
|
||||
Bonus-Multiplikatoren (basierend auf Versuch, in dem gelöst wurde):
|
||||
- Gelöst in 1-2 Versuchen: ×1.2 (20% Bonus)
|
||||
- Gelöst in 3-4 Versuchen: ×1.1 (10% Bonus)
|
||||
- Gelöst in 5-6 Versuchen: ×1.0 (kein Bonus)
|
||||
- Gelöst in 7 Versuchen: ×0.9 (10% Abzug)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 2 (1 Fehler): (90 + 20 - 5) × 1.2 = **126 Punkte**
|
||||
- Gelöst in Versuch 4 (3 Fehler): (90 + 20 - 15) × 1.1 = **104.5 → 105 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- Multiplikator ist einfach zu verstehen ("20% Bonus für schnelles Lösen")
|
||||
- Basis-System bleibt ähnlich
|
||||
- Gerechte Bestrafung für viele Versuche
|
||||
|
||||
### Nachteile
|
||||
- Multiplikatoren müssen berechnet werden (könnte kompliziert wirken)
|
||||
- Kombination aus Basis + Multiplikator kann verwirrend sein
|
||||
|
||||
---
|
||||
|
||||
## Option 4: Kombiniertes System
|
||||
|
||||
### Konzept
|
||||
Höhere Abzüge + kleine Motivations-Boni.
|
||||
|
||||
```
|
||||
Basis-System (höhere Abzüge):
|
||||
- Falsche Antwort: -5 Punkte (statt -3)
|
||||
- Skip: -7 Punkte (statt -5)
|
||||
- Richtige Antwort: +20 Punkte (bleibt)
|
||||
|
||||
Motivations-Boni:
|
||||
- "Erstversuch" Bonus: +2 Punkte wenn erster Versuch nicht skipped wurde
|
||||
- "Perfekter Durchlauf": +5 Bonus wenn kein Skip verwendet wurde
|
||||
- "Knapp daneben": +1 Punkt für Versuche, die fast richtig waren (optional, komplex)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: 90 + 20 + 2 + 5 = **117 Punkte**
|
||||
- Gelöst in Versuch 4 (3 Fehler, kein Skip): 90 + 20 - 15 + 5 = **100 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler, 2 Skips): 90 + 20 - 30 - 14 = **66 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Einfach verständlich**: Basis + kleine Boni
|
||||
- **Motivierend**: Positive Verstärkung für gutes Verhalten
|
||||
- **Fair**: Höhere Abzüge sorgen für differenzierten Score
|
||||
|
||||
### Nachteile
|
||||
- Mehrere kleine Boni können unübersichtlich werden
|
||||
- "Knapp daneben" ist schwer zu implementieren
|
||||
|
||||
---
|
||||
|
||||
## Option 5: Streak-System (Langfristige Motivation)
|
||||
|
||||
### Konzept
|
||||
Zusätzliche Belohnungen für konsequentes Spielen über mehrere Tage.
|
||||
|
||||
```
|
||||
Tägliche Streaks:
|
||||
- 3 Tage in Folge gelöst: +5 Bonus-Punkte
|
||||
- 7 Tage: +10 Bonus-Punkte
|
||||
- 30 Tage: +15 Bonus-Punkte
|
||||
```
|
||||
|
||||
**Kombiniert mit einem der anderen Systeme** (z.B. Option 2 oder 4).
|
||||
|
||||
### Vorteile
|
||||
- Langfristige Spielermotivation
|
||||
- Belohnt Engagement
|
||||
|
||||
### Nachteile
|
||||
- Braucht Tracking über mehrere Tage
|
||||
- Löst nicht das Hauptproblem (zu hoher Score bei vielen Versuchen)
|
||||
|
||||
---
|
||||
|
||||
## Option 6: Multiplikator-System (Vereinfacht)
|
||||
|
||||
### Konzept
|
||||
Höhere Abzüge + einfache Multiplikatoren für Versuchszahl.
|
||||
|
||||
```
|
||||
Höhere Basis-Abzüge:
|
||||
- Falsche Antwort: -5 Punkte
|
||||
- Skip: -7 Punkte
|
||||
|
||||
Multiplikator basierend auf Versuch, in dem gelöst wurde:
|
||||
- Versuch 1: ×1.5 (50% Bonus) → Sehr schnelles Lösen
|
||||
- Versuch 2: ×1.3 (30% Bonus)
|
||||
- Versuch 3: ×1.1 (10% Bonus)
|
||||
- Versuch 4: ×1.0 (kein Bonus/Aufschlag)
|
||||
- Versuch 5+: ×0.9 (10% Abzug)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: (90 + 20) × 1.5 = **165 Punkte** ⭐⭐⭐
|
||||
- Gelöst in Versuch 3 (2 Fehler): (90 + 20 - 10) × 1.1 = **110 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Sehr transparent**: "50% Bonus für Erstversuch" ist einfach zu verstehen
|
||||
- **Stark motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||
- **Fair**: Viele Versuche = niedriger Score
|
||||
|
||||
### Nachteile
|
||||
- Multiplikatoren könnten als zu komplex empfunden werden
|
||||
- Hohe Scores bei frühen Erfolgen (könnte als "zu leicht" empfunden werden)
|
||||
|
||||
---
|
||||
|
||||
## Empfehlungen
|
||||
|
||||
### Für Transparenz und Einfachheit: **Option 2 oder Option 4**
|
||||
|
||||
**Option 2 (Bonus-Malus)** ist am transparentesten:
|
||||
- Klare Werte pro Versuch
|
||||
- Einfach zu kommunizieren: "Erstversuch gibt +30, jeder weitere Versuch reduziert den Bonus"
|
||||
- Fair und motivierend
|
||||
|
||||
**Option 4 (Kombiniert)** ist am einfachsten:
|
||||
- Basis-System bleibt ähnlich (nur höhere Abzüge)
|
||||
- Zusätzliche kleine Boni sind optional und motivierend
|
||||
- Sehr einfach zu verstehen
|
||||
|
||||
### Für maximale Motivation: **Option 6**
|
||||
|
||||
- Hohe Belohnungen für schnelles Lösen
|
||||
- Einfache Multiplikatoren ("50% Bonus")
|
||||
- Sehr fair für viele Versuche
|
||||
|
||||
---
|
||||
|
||||
## Implementierungs-Hinweise
|
||||
|
||||
### UI-Kommunikation
|
||||
Welche Option auch gewählt wird - sie muss im Spiel klar kommuniziert werden:
|
||||
- Tooltips bei Versuchen
|
||||
- Score-Breakdown zeigt Abzüge/Boni pro Versuch
|
||||
- Vorschau: "Dieser Versuch würde X Punkte kosten/geben"
|
||||
|
||||
### Testing
|
||||
Vor der Implementierung sollten verschiedene Szenarien durchgespielt werden:
|
||||
- Erstversuch-Lösung
|
||||
- Mittlere Versuche (3-4)
|
||||
- Knappe Lösung (6-7 Versuche)
|
||||
- Mit/ohne Skips
|
||||
- Mit/ohne Replays
|
||||
|
||||
### Migration
|
||||
- Bestehende Scores können nicht einfach migriert werden
|
||||
- Neue Regeln gelten ab Start des neuen Systems
|
||||
- Eventuell: "New Scoring System" Ankündigung
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implementiert: Abzüge für zusätzliche Sekunden
|
||||
|
||||
**Status:** ✅ **Aktuell implementiert**
|
||||
|
||||
Bei falschen Rateversuchen werden zusätzlich **-5 Punkte für die Track-Verlängerung** abgezogen:
|
||||
- Falsche Antwort (Rateversuch): -3 Punkte (falsch) + -5 Punkte (Verlängerung) = **-8 Punkte total**
|
||||
- Skip: -5 Punkte (kein zusätzlicher Abzug, da Skip keine Verlängerung bedeutet)
|
||||
|
||||
**Vorteile:**
|
||||
- ✅ Reflektiert den "Hilfsmittel"-Charakter der zusätzlichen Sekunden
|
||||
- ✅ Macht viele Versuche deutlich teurer
|
||||
- ✅ Fairer Score bei vielen Versuchen
|
||||
- ✅ Transparent: Klar getrennt als "Wrong guess" und "Track extension"
|
||||
|
||||
**Hinweis:** Dies ist die erste Anpassung des Scoring-Systems. Weitere Optionen (siehe oben) können in Zukunft ergänzt werden.
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
1. Sollen Replays weiterhin -1 Punkt kosten?
|
||||
2. Soll das Jahr-Bonus-System (+10) beibehalten werden?
|
||||
3. Wie wichtig ist Backward-Compatibility mit bestehenden Scores?
|
||||
4. Soll es eine "Preview"-Funktion geben ("Dieser Versuch kostet X Punkte")?
|
||||
5. Sollen zusätzlich freigeschaltete Sekunden (Unlock-Steps) zusätzlich Punkte kosten?
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
📝 **Erstellt:** 2024-12-01
|
||||
✅ **Erste Änderung implementiert:** 2024-12-01 - Track-Verlängerung kostet jetzt -5 Punkte bei falschen Rateversuchen
|
||||
🔄 **Status:** Teilweise umgesetzt
|
||||
💡 **Nächste Schritte:** Weitere Optionen können bei Bedarf ergänzt werden (siehe Optionen oben)
|
||||
|
||||
235
docs/SEO_TESTING.md
Normal file
235
docs/SEO_TESTING.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# SEO & Open Graph Testing Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
Diese Anleitung zeigt dir, wie du die SEO-Implementierung (Meta-Tags, Open Graph, Twitter Cards) testen kannst.
|
||||
|
||||
## Lokales Testen
|
||||
|
||||
### 1. Browser-Entwicklertools
|
||||
|
||||
1. **App starten:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000`
|
||||
|
||||
2. **Meta-Tags im HTML prüfen:**
|
||||
- Öffne eine Seite (z.B. `http://localhost:3000/en` oder `http://localhost:3000/de/about`)
|
||||
- Rechtsklick → "Seite untersuchen" (F12)
|
||||
- Tab "Elements" → `<head>` Bereich erweitern
|
||||
- Suche nach Meta-Tags:
|
||||
- `<meta property="og:title">`
|
||||
- `<meta property="og:description">`
|
||||
- `<meta property="og:image">`
|
||||
- `<meta name="twitter:card">`
|
||||
|
||||
3. **View Page Source:**
|
||||
- Rechtsklick → "Seitenquelltext anzeigen"
|
||||
- Suche nach "og:" oder "twitter:" um alle Open Graph und Twitter Meta-Tags zu sehen
|
||||
|
||||
### 2. cURL-Test (für schnelle Prüfung)
|
||||
|
||||
```bash
|
||||
# Prüfe Meta-Tags einer Seite
|
||||
curl -s http://localhost:3000/en | grep -i "og:\|twitter:"
|
||||
```
|
||||
|
||||
### 3. Node.js-Script zum Testen
|
||||
|
||||
Erstelle eine Test-Datei `test-og.js`:
|
||||
|
||||
```javascript
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function fetchHTML(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
client.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => resolve(data));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function testOGTags(url) {
|
||||
try {
|
||||
const html = await fetchHTML(url);
|
||||
const ogTags = {
|
||||
title: html.match(/<meta property="og:title" content="([^"]*)"/)?.[1],
|
||||
description: html.match(/<meta property="og:description" content="([^"]*)"/)?.[1],
|
||||
image: html.match(/<meta property="og:image" content="([^"]*)"/)?.[1],
|
||||
url: html.match(/<meta property="og:url" content="([^"]*)"/)?.[1],
|
||||
};
|
||||
|
||||
console.log('Open Graph Tags:', ogTags);
|
||||
return ogTags;
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
testOGTags('http://localhost:3000/en');
|
||||
```
|
||||
|
||||
## Online-Tools (für Produktions-URLs)
|
||||
|
||||
### 1. Facebook Sharing Debugger (Empfohlen)
|
||||
|
||||
**URL:** https://developers.facebook.com/tools/debug/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine Produktions-URL ein (z.B. `https://hoerdle.de/en`)
|
||||
3. Klicke auf "Debuggen"
|
||||
4. Prüfe die Vorschau und alle Meta-Tags
|
||||
|
||||
**Wichtig:**
|
||||
- Facebook cached die Vorschau! Klicke auf "Scraping erneut ausführen" um den Cache zu leeren
|
||||
- Funktioniert nur mit öffentlich erreichbaren URLs (nicht localhost)
|
||||
|
||||
### 2. Twitter Card Validator
|
||||
|
||||
**URL:** https://cards-dev.twitter.com/validator
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine Produktions-URL ein
|
||||
3. Prüfe die Twitter Card Vorschau
|
||||
|
||||
**Hinweis:** Twitter hat den Validator eingestellt, aber die Cards funktionieren trotzdem. Du kannst auch einfach einen Tweet mit deiner URL erstellen, um zu sehen, ob die Card angezeigt wird.
|
||||
|
||||
### 3. LinkedIn Post Inspector
|
||||
|
||||
**URL:** https://www.linkedin.com/post-inspector/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL (Login erforderlich)
|
||||
2. Gib deine Produktions-URL ein
|
||||
3. Prüfe die LinkedIn Vorschau
|
||||
|
||||
### 4. OpenGraph.xyz (Universelles Tool)
|
||||
|
||||
**URL:** https://www.opengraph.xyz/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine URL ein
|
||||
3. Sieh dir alle Open Graph und Twitter Meta-Tags an
|
||||
4. Sieh dir die Vorschau für verschiedene Plattformen an
|
||||
|
||||
### 5. Metatags.io
|
||||
|
||||
**URL:** https://metatags.io/
|
||||
|
||||
**Verwendung:**
|
||||
- Gebe deine URL ein
|
||||
- Sieh dir alle Meta-Tags an
|
||||
- Vorschau für verschiedene Plattformen
|
||||
|
||||
## Produktions-Test (hoerdle.de / hördle.de)
|
||||
|
||||
Sobald die App deployed ist, kannst du alle oben genannten Tools mit deinen Produktions-URLs verwenden:
|
||||
|
||||
### Test-URLs:
|
||||
|
||||
- Homepage (EN): `https://hoerdle.de/en`
|
||||
- Homepage (DE): `https://hoerdle.de/de`
|
||||
- About (EN): `https://hoerdle.de/en/about`
|
||||
- About (DE): `https://hoerdle.de/de/about`
|
||||
- Genre-Seiten: `https://hoerdle.de/en/Rock` (Beispiel)
|
||||
- Special-Seiten: `https://hoerdle.de/en/special/Weihnachtslieder` (Beispiel)
|
||||
|
||||
### Schnelltest mit cURL:
|
||||
|
||||
```bash
|
||||
# Teste Homepage
|
||||
curl -s https://hoerdle.de/en | grep -E "og:|twitter:" | head -10
|
||||
|
||||
# Teste About-Seite
|
||||
curl -s https://hoerdle.de/de/about | grep -E "og:|twitter:" | head -10
|
||||
```
|
||||
|
||||
## Erwartete Meta-Tags
|
||||
|
||||
Die folgenden Meta-Tags sollten auf allen Seiten vorhanden sein:
|
||||
|
||||
### Open Graph Tags:
|
||||
- `og:title` - Seitentitel
|
||||
- `og:description` - Seitenbeschreibung
|
||||
- `og:image` - Bild für Social Media (Standard: `/favicon.ico`)
|
||||
- `og:url` - Canonical URL
|
||||
- `og:type` - Typ (sollte "website" sein)
|
||||
- `og:site_name` - Name der Site
|
||||
- `og:locale` - Sprache (de/en)
|
||||
|
||||
### Twitter Tags:
|
||||
- `twitter:card` - Card-Typ (sollte "summary_large_image" sein)
|
||||
- `twitter:title` - Titel
|
||||
- `twitter:description` - Beschreibung
|
||||
- `twitter:image` - Bild
|
||||
|
||||
### Canonical & Alternates:
|
||||
- `<link rel="canonical">` - Canonical URL
|
||||
- `<link rel="alternate" hreflang="de">` - Deutsche Version
|
||||
- `<link rel="alternate" hreflang="en">` - Englische Version
|
||||
- `<link rel="alternate" hreflang="x-default">` - Standard-Version
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Meta-Tags werden nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob die App läuft: `npm run dev`
|
||||
2. Prüfe Browser-Console auf Fehler
|
||||
3. Stelle sicher, dass `generateMetadata` in der Seite exportiert ist
|
||||
4. Prüfe, ob `lib/metadata.ts` korrekt importiert wird
|
||||
|
||||
### Problem: Open Graph Image wird nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob das Bild unter `/favicon.ico` existiert (oder konfiguriertes OG-Image)
|
||||
2. Für bessere Ergebnisse: Erstelle ein dediziertes Open Graph Bild (1200x630px)
|
||||
3. Platziere es in `public/og-image.png`
|
||||
4. Setze in `.env`: `NEXT_PUBLIC_OG_IMAGE=/og-image.png`
|
||||
|
||||
### Problem: Facebook zeigt alte Vorschau
|
||||
|
||||
**Lösung:**
|
||||
1. Öffne Facebook Sharing Debugger
|
||||
2. Gib deine URL ein
|
||||
3. Klicke auf "Scraping erneut ausführen" (mehrfach, falls nötig)
|
||||
4. Facebook cached die Vorschau - Cache kann mehrere Stunden dauern
|
||||
|
||||
### Problem: Domain-Erkennung funktioniert nicht
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe `lib/seo.ts` - `getBaseUrl()` Funktion
|
||||
2. Stelle sicher, dass Request-Headers korrekt sind
|
||||
3. In Produktion: Prüfe, ob Proxy-Headers (`x-forwarded-host`) korrekt gesetzt sind
|
||||
|
||||
## Open Graph Bild optimieren
|
||||
|
||||
Für bessere Social Media Vorschauen solltest du ein dediziertes OG-Bild erstellen:
|
||||
|
||||
**Empfohlene Größe:** 1200x630px
|
||||
**Format:** PNG oder JPG
|
||||
**Pfad:** `public/og-image.png`
|
||||
|
||||
**Konfiguration:**
|
||||
```bash
|
||||
# In .env
|
||||
NEXT_PUBLIC_OG_IMAGE=/og-image.png
|
||||
```
|
||||
|
||||
Dann wird dieses Bild in allen Open Graph Meta-Tags verwendet.
|
||||
|
||||
## Nützliche Links
|
||||
|
||||
- [Open Graph Protocol Dokumentation](https://ogp.me/)
|
||||
- [Twitter Cards Dokumentation](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
|
||||
- [Facebook Sharing Best Practices](https://developers.facebook.com/docs/sharing/webmasters)
|
||||
|
||||
206
docs/TROUBLESHOOTING.md
Normal file
206
docs/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
## Application Error: "a server-side exception has occurred"
|
||||
|
||||
Dieser Fehler tritt auf, wenn die Next.js-Anwendung auf dem Server einen Fehler hat.
|
||||
|
||||
### ⚠️ Datenbank-Berechtigungen (wenn DB von anderem Server kopiert wurde)
|
||||
|
||||
**Symptom**: Application Error nach dem Kopieren einer Datenbank von einem anderen Server
|
||||
|
||||
**Ursache**: SQLite benötigt Schreibrechte auf:
|
||||
- Die Datenbankdatei selbst (`prod.db`)
|
||||
- Das Datenbankverzeichnis (für temporäre Dateien wie `-wal`, `-shm`)
|
||||
|
||||
**Sofort-Lösung (auf dem Server ausführen)**:
|
||||
```bash
|
||||
# 1. Setze Berechtigungen für Datenbankverzeichnis und Datei
|
||||
chmod 775 ./data
|
||||
chmod 664 ./data/prod.db
|
||||
|
||||
# 2. Falls temporäre SQLite-Dateien existieren, auch diese:
|
||||
chmod 664 ./data/*.db-wal 2>/dev/null || true
|
||||
chmod 664 ./data/*.db-shm 2>/dev/null || true
|
||||
|
||||
# 3. Oder verwende das Fix-Skript:
|
||||
./scripts/fix-database-permissions.sh
|
||||
|
||||
# 4. Container neu starten
|
||||
docker compose restart hoerdle
|
||||
|
||||
# 5. Logs prüfen
|
||||
docker logs hoerdle --tail=50
|
||||
```
|
||||
|
||||
**Warum passiert das?**
|
||||
- Wenn du eine Datenbankdatei von einem anderen Server kopierst, behält sie die ursprünglichen Berechtigungen
|
||||
- SQLite muss Schreibrechte haben, um zu funktionieren
|
||||
- Auch das Verzeichnis braucht Schreibrechte (für SQLite-WAL-Modus)
|
||||
|
||||
### Sofort-Diagnose (auf dem Server ausführen)
|
||||
|
||||
```bash
|
||||
# 1. Container-Logs prüfen (die wichtigste Information!)
|
||||
docker logs hoerdle --tail=100
|
||||
|
||||
# 2. Container-Status prüfen
|
||||
docker ps | grep hoerdle
|
||||
|
||||
# 3. Prüfe ob Datenbank existiert
|
||||
docker exec hoerdle ls -lh /app/data/prod.db
|
||||
|
||||
# 4. Prüfe ob Server auf Port 3000 antwortet (intern)
|
||||
docker exec hoerdle curl -f http://localhost:3000/api/daily
|
||||
|
||||
# 5. Prüfe Health Check
|
||||
docker inspect hoerdle --format='{{json .State.Health}}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Häufige Ursachen und Lösungen
|
||||
|
||||
#### 1. Datenbankfehler / Migrationen fehlgeschlagen
|
||||
|
||||
**Symptom**: Logs zeigen Prisma-Fehler oder "database locked"
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Container-Logs prüfen
|
||||
docker logs hoerdle | grep -i "migration\|database\|prisma"
|
||||
|
||||
# Falls Migrationen fehlgeschlagen sind:
|
||||
docker compose restart hoerdle
|
||||
|
||||
# Bei persistierenden Problemen: Datenbank-Backup prüfen
|
||||
ls -lh ./backups/
|
||||
```
|
||||
|
||||
#### 2. Container läuft nicht oder ist crashed
|
||||
|
||||
**Symptom**: Container existiert nicht oder Status zeigt "Exited"
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Container-Status prüfen
|
||||
docker ps -a | grep hoerdle
|
||||
|
||||
# Container neu starten
|
||||
docker compose up -d
|
||||
|
||||
# Falls Container nicht startet, Logs prüfen
|
||||
docker logs hoerdle --tail=200
|
||||
```
|
||||
|
||||
#### 3. Caddy kann Container nicht erreichen
|
||||
|
||||
**Symptom**: 502 Bad Gateway oder "connection refused" in Caddy-Logs
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Prüfe ob hoerdle Container läuft
|
||||
docker ps | grep hoerdle
|
||||
|
||||
# Prüfe Netzwerk
|
||||
docker network inspect hoerdle_default
|
||||
|
||||
# Prüfe Caddy-Logs
|
||||
docker logs hoerdle-caddy --tail=50
|
||||
|
||||
# Stelle sicher, dass Caddyfile Port 3000 verwendet (nicht 3010!)
|
||||
grep "reverse_proxy" Caddyfile
|
||||
```
|
||||
|
||||
#### 4. Fehlende Umgebungsvariablen
|
||||
|
||||
**Symptom**: Logs zeigen undefined variables
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Prüfe wichtige Umgebungsvariablen
|
||||
docker exec hoerdle env | grep -E "DATABASE_URL|NODE_ENV"
|
||||
|
||||
# Prüfe .env Datei (falls vorhanden)
|
||||
cat .env | grep DATABASE_URL
|
||||
```
|
||||
|
||||
#### 5. Build-Fehler oder fehlerhafte Dateien
|
||||
|
||||
**Symptom**: Container startet, aber App crasht sofort
|
||||
|
||||
**Lösung**:
|
||||
```bash
|
||||
# Container komplett neu bauen
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Prüfe Build-Logs
|
||||
docker compose build 2>&1 | tee build.log
|
||||
```
|
||||
|
||||
### Detaillierte Log-Analyse
|
||||
|
||||
```bash
|
||||
# Alle Fehler in Logs finden
|
||||
docker logs hoerdle 2>&1 | grep -i -E "error|exception|fatal|panic" | tail -50
|
||||
|
||||
# Prisma-spezifische Fehler
|
||||
docker logs hoerdle 2>&1 | grep -i prisma | tail -20
|
||||
|
||||
# Next.js-spezifische Fehler
|
||||
docker logs hoerdle 2>&1 | grep -i "next\|react" | tail -20
|
||||
```
|
||||
|
||||
### Netzwerk-Debugging
|
||||
|
||||
```bash
|
||||
# Teste Verbindung von Caddy zu Hördle
|
||||
docker exec hoerdle-caddy wget -O- http://hoerdle:3000/api/daily
|
||||
|
||||
# Prüfe alle Container im Netzwerk
|
||||
docker network inspect hoerdle_default --format='{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}'
|
||||
```
|
||||
|
||||
### Datenbank-Debugging
|
||||
|
||||
```bash
|
||||
# Prüfe Datenbank-Integrität
|
||||
docker exec hoerdle npx prisma db pull
|
||||
|
||||
# Prüfe Datenbank-Struktur
|
||||
docker exec hoerdle npx prisma studio &
|
||||
# (dann Browser öffnen - erfordert X11 forwarding oder lokalen Zugriff)
|
||||
```
|
||||
|
||||
### Quick-Fix: Vollständiger Neustart
|
||||
|
||||
Wenn nichts anderes hilft:
|
||||
|
||||
```bash
|
||||
# 1. Backup erstellen
|
||||
cp ./data/prod.db ./backups/prod_$(date +%Y%m%d_%H%M%S).db
|
||||
|
||||
# 2. Container stoppen
|
||||
docker compose down
|
||||
|
||||
# 3. Container neu starten
|
||||
docker compose up -d
|
||||
|
||||
# 4. Logs beobachten
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## Bei weiterem Bedarf
|
||||
|
||||
Sammle folgende Informationen für weitere Hilfe:
|
||||
|
||||
```bash
|
||||
echo "=== Container Status ===" && \
|
||||
docker ps -a | grep hoerdle && \
|
||||
echo -e "\n=== Letzte 50 Log-Zeilen ===" && \
|
||||
docker logs hoerdle --tail=50 && \
|
||||
echo -e "\n=== Fehler in Logs ===" && \
|
||||
docker logs hoerdle 2>&1 | grep -i error | tail -20
|
||||
```
|
||||
|
||||
Kopiere die vollständige Ausgabe und sende sie weiter.
|
||||
|
||||
99
docs/WHITE_LABEL.md
Normal file
99
docs/WHITE_LABEL.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# White Labeling Guide
|
||||
|
||||
This application is designed to be easily white-labeled. You can customize the branding, colors, and configuration without modifying the core code.
|
||||
|
||||
## Configuration
|
||||
|
||||
The application is configured via environment variables. You can set these in a `.env` or `.env.local` file.
|
||||
|
||||
### Branding
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
|
||||
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.de` |
|
||||
|
||||
### Analytics (Plausible)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.example.com/js/script.js` |
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||
|
||||
### Credits
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_CREDITS_ENABLED` | Enable/disable footer credits (`true`/`false`). | `true` |
|
||||
| `NEXT_PUBLIC_CREDITS_TEXT` | Text before the link. | `Vibe coded with ☕ and 🍺 by` |
|
||||
| `NEXT_PUBLIC_CREDITS_LINK_TEXT` | Text of the link. | `@elpatron@digitalcourage.social` |
|
||||
| `NEXT_PUBLIC_CREDITS_LINK_URL` | URL of the link. | `https://digitalcourage.social/@elpatron` |
|
||||
|
||||
## Theming
|
||||
|
||||
The application uses CSS variables for theming. You can override these variables in your own CSS file or by modifying `app/globals.css`.
|
||||
|
||||
### Key Colors
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `--primary` | Main action color (buttons). | `#000000` |
|
||||
| `--secondary` | Secondary actions. | `#4b5563` |
|
||||
| `--accent` | Accent color. | `#667eea` |
|
||||
| `--success` | Success state (correct guess). | `#22c55e` |
|
||||
| `--danger` | Error state (wrong guess). | `#ef4444` |
|
||||
| `--warning` | Warning state (stars). | `#ffc107` |
|
||||
| `--muted` | Muted backgrounds. | `#f3f4f6` |
|
||||
|
||||
### Example: Red Theme
|
||||
|
||||
To create a red-themed version, add this to your CSS:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary: #dc2626;
|
||||
--accent: #ef4444;
|
||||
--accent-gradient: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
|
||||
}
|
||||
```
|
||||
|
||||
## Assets
|
||||
|
||||
To replace the logo and icons:
|
||||
1. Replace `public/favicon.ico`.
|
||||
2. Replace `public/icon.png` (if it exists).
|
||||
3. Update `app/manifest.ts` if you have custom icon paths.
|
||||
3. Update `app/manifest.ts` if you have custom icon paths.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
When deploying with Docker, please note that **Next.js inlines `NEXT_PUBLIC_` environment variables at build time**.
|
||||
|
||||
This means you cannot simply change the environment variables in `docker-compose.yml` and restart the container to change the branding. You must **rebuild the image**.
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
1. Create a `.env` file with your custom configuration:
|
||||
```bash
|
||||
NEXT_PUBLIC_APP_NAME="My Music Game"
|
||||
NEXT_PUBLIC_THEME_COLOR="#ff0000"
|
||||
# ... other variables
|
||||
```
|
||||
|
||||
2. Ensure your `docker-compose.yml` passes these variables as build arguments (already configured in `docker-compose.example.yml`):
|
||||
```yaml
|
||||
services:
|
||||
hoerdle:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||
# ...
|
||||
```
|
||||
|
||||
3. Build and start the container:
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
20
i18n/request.ts
Normal file
20
i18n/request.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
|
||||
const locales = ['en', 'de'] as const;
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// `requestLocale` kommt von next-intl (z.B. aus dem [locale]-Segment oder Fallback)
|
||||
let locale = await requestLocale;
|
||||
|
||||
console.log('[i18n/request] incoming requestLocale:', locale);
|
||||
|
||||
if (!locale || !locales.includes(locale as (typeof locales)[number])) {
|
||||
locale = 'en';
|
||||
console.log('[i18n/request] falling back to default locale:', locale);
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default
|
||||
};
|
||||
});
|
||||
58
lib/auth.ts
58
lib/auth.ts
@@ -1,4 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient, Curator } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export type StaffContext =
|
||||
| { role: 'admin' }
|
||||
| { role: 'curator'; curator: Curator };
|
||||
|
||||
/**
|
||||
* Authentication middleware for admin API routes
|
||||
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
|
||||
return null; // Auth successful
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve current staff (admin or curator) from headers.
|
||||
*
|
||||
* Admin:
|
||||
* - x-admin-auth: 'authenticated'
|
||||
*
|
||||
* Curator:
|
||||
* - x-curator-auth: 'authenticated'
|
||||
* - x-curator-username: <username>
|
||||
*/
|
||||
export async function getStaffContext(request: NextRequest): Promise<StaffContext | null> {
|
||||
const adminHeader = request.headers.get('x-admin-auth');
|
||||
if (adminHeader === 'authenticated') {
|
||||
return { role: 'admin' };
|
||||
}
|
||||
|
||||
const curatorAuth = request.headers.get('x-curator-auth');
|
||||
const curatorUsername = request.headers.get('x-curator-username');
|
||||
|
||||
if (curatorAuth === 'authenticated' && curatorUsername) {
|
||||
const curator = await prisma.curator.findUnique({
|
||||
where: { username: curatorUsername },
|
||||
});
|
||||
|
||||
if (curator) {
|
||||
return { role: 'curator', curator };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that the current request is authenticated as staff (admin or curator).
|
||||
* Returns either an error response or a resolved context.
|
||||
*/
|
||||
export async function requireStaffAuth(request: NextRequest): Promise<{ error?: NextResponse; context?: StaffContext }> {
|
||||
const context = await getStaffContext(request);
|
||||
|
||||
if (!context) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Unauthorized - Staff authentication required' },
|
||||
{ status: 401 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { context };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to verify admin password
|
||||
*/
|
||||
|
||||
20
lib/config.ts
Normal file
20
lib/config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const config = {
|
||||
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
||||
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.de',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js',
|
||||
colors: {
|
||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
||||
},
|
||||
credits: {
|
||||
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Made with 💚, ☕ and 🍺 by',
|
||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||
},
|
||||
seo: {
|
||||
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/api/og-image',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
|
||||
}
|
||||
};
|
||||
17
lib/curatorAuth.ts
Normal file
17
lib/curatorAuth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function getCuratorAuthHeaders() {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
'x-curator-auth': '',
|
||||
'x-curator-username': '',
|
||||
};
|
||||
}
|
||||
|
||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
||||
return {
|
||||
'x-curator-auth': authToken || '',
|
||||
'x-curator-username': username,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Genre, Special } from '@prisma/client';
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
||||
try {
|
||||
const today = getTodayISOString();
|
||||
let genreId: number | null = null;
|
||||
|
||||
if (genreName) {
|
||||
const genre = await prisma.genre.findUnique({
|
||||
where: { name: genreName }
|
||||
});
|
||||
if (genre) {
|
||||
genreId = genre.id;
|
||||
} else {
|
||||
return null; // Genre not found
|
||||
}
|
||||
if (genre) {
|
||||
genreId = genre.id;
|
||||
}
|
||||
|
||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||
@@ -27,8 +20,6 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
include: { song: true },
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (!dailyPuzzle) {
|
||||
// Get songs available for this genre
|
||||
const whereClause = genreId
|
||||
@@ -45,7 +36,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
});
|
||||
|
||||
if (allSongs.length === 0) {
|
||||
console.log(`[Daily Puzzle] No songs available for genre: ${genreName || 'Global'}`);
|
||||
console.log(`[Daily Puzzle] No songs available for genre: ${genre ? JSON.stringify(genre.name) : 'Global'}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,13 +49,15 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
// Calculate total weight
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
|
||||
// Pick a random song based on weights
|
||||
// Pick a random song based on weights using cumulative weights
|
||||
// This ensures proper distribution and handles edge cases
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSong = weightedSongs[0].song;
|
||||
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song
|
||||
|
||||
let cumulativeWeight = 0;
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
cumulativeWeight += item.weight;
|
||||
if (random <= cumulativeWeight) {
|
||||
selectedSong = item.song;
|
||||
break;
|
||||
}
|
||||
@@ -80,7 +73,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
},
|
||||
include: { song: true },
|
||||
});
|
||||
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genreName || 'Global'}) with song: ${selectedSong.title}`);
|
||||
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genre ? JSON.stringify(genre.name) : 'Global'}) with song: ${selectedSong.title}`);
|
||||
} catch (e) {
|
||||
// Handle race condition
|
||||
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)');
|
||||
@@ -119,7 +112,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
artist: dailyPuzzle.song.artist,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
releaseYear: dailyPuzzle.song.releaseYear,
|
||||
genre: genreName
|
||||
genre: genre ? genre.name : null
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
@@ -128,16 +121,10 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateSpecialPuzzle(specialName: string) {
|
||||
export async function getOrCreateSpecialPuzzle(special: Special) {
|
||||
try {
|
||||
const today = getTodayISOString();
|
||||
|
||||
const special = await prisma.special.findUnique({
|
||||
where: { name: specialName }
|
||||
});
|
||||
|
||||
if (!special) return null;
|
||||
|
||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||
where: {
|
||||
date: today,
|
||||
@@ -171,11 +158,13 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
||||
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSpecialSong = weightedSongs[0].specialSong;
|
||||
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
|
||||
|
||||
// Pick a random song based on weights using cumulative weights
|
||||
let cumulativeWeight = 0;
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
cumulativeWeight += item.weight;
|
||||
if (random <= cumulativeWeight) {
|
||||
selectedSpecialSong = item.specialSong;
|
||||
break;
|
||||
}
|
||||
@@ -232,7 +221,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
||||
artist: dailyPuzzle.song.artist,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
releaseYear: dailyPuzzle.song.releaseYear,
|
||||
special: specialName,
|
||||
special: special.name,
|
||||
maxAttempts: special.maxAttempts,
|
||||
unlockSteps: JSON.parse(special.unlockSteps),
|
||||
startTime: specialSong?.startTime || 0
|
||||
|
||||
47
lib/externalPuzzles.ts
Normal file
47
lib/externalPuzzles.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type ExternalPuzzle = {
|
||||
id: string;
|
||||
nameDe: string;
|
||||
nameEn: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Zentrale Liste externer Rätselangebote.
|
||||
*
|
||||
* Erweiterung: Einfach neuen Eintrag in dieses Array hinzufügen.
|
||||
*/
|
||||
export const externalPuzzles: ExternalPuzzle[] = [
|
||||
{
|
||||
id: 'pastpuzzle',
|
||||
nameDe: 'Past Puzzle',
|
||||
nameEn: 'Past Puzzle',
|
||||
url: 'https://www.pastpuzzle.de/#/',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'woerdle',
|
||||
nameDe: 'Wördle',
|
||||
nameEn: 'Wördle',
|
||||
url: 'https://www.wördle.de',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'ciddle',
|
||||
nameDe: 'Ciddle',
|
||||
nameEn: 'Ciddle',
|
||||
url: 'https://ciddle.winklerweb.net',
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function getRandomExternalPuzzle(): ExternalPuzzle | null {
|
||||
const activePuzzles = externalPuzzles.filter(p => p.isActive !== false);
|
||||
if (activePuzzles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.floor(Math.random() * activePuzzles.length);
|
||||
return activePuzzles[index] ?? null;
|
||||
}
|
||||
|
||||
|
||||
68
lib/extraPuzzlesTracker.ts
Normal file
68
lib/extraPuzzlesTracker.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
|
||||
const DAILY_PLAYED_PREFIX = 'hoerdle_daily_played_';
|
||||
const EXTRA_POPOVER_PREFIX = 'hoerdle_extra_puzzles_shown_';
|
||||
|
||||
function getTodayKey(prefix: string): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const today = getTodayISOString();
|
||||
return `${prefix}${today}`;
|
||||
}
|
||||
|
||||
export function markDailyPuzzlePlayedToday(genreKey: string) {
|
||||
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
const list: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!list.includes(genreKey)) {
|
||||
list.push(genreKey);
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(list));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to mark daily puzzle as played', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPlayedAllDailyPuzzlesForToday(requiredGenreKeys: string[]): boolean {
|
||||
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||
if (!storageKey) return false;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
const played: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(played) || played.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return requiredGenreKeys.every(key => played.includes(key));
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to read played puzzles', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSeenExtraPuzzlesPopoverToday(): boolean {
|
||||
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||
if (!storageKey) return false;
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === 'true';
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to read popover state', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markExtraPuzzlesPopoverShownToday() {
|
||||
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, 'true');
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to persist popover state', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
lib/gameState.ts
169
lib/gameState.ts
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
|
||||
|
||||
export interface GameState {
|
||||
date: string;
|
||||
@@ -27,17 +28,19 @@ export interface Statistics {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
||||
|
||||
const INITIAL_SCORE = 90;
|
||||
|
||||
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
|
||||
export function useGameState(
|
||||
genre: string | null = null,
|
||||
maxAttempts: number = 7,
|
||||
isSpecial: boolean = false
|
||||
) {
|
||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
|
||||
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
||||
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
||||
|
||||
// Get genre key for backend storage
|
||||
// For specials, genre contains the special name
|
||||
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
|
||||
const createNewState = (date: string): GameState => ({
|
||||
date,
|
||||
@@ -52,72 +55,94 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
yearGuessed: false
|
||||
});
|
||||
|
||||
const createNewStatistics = (): Statistics => ({
|
||||
solvedIn1: 0,
|
||||
solvedIn2: 0,
|
||||
solvedIn3: 0,
|
||||
solvedIn4: 0,
|
||||
solvedIn5: 0,
|
||||
solvedIn6: 0,
|
||||
solvedIn7: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Load game state
|
||||
const storageKey = getStorageKey();
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
const today = getTodayISOString();
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.date === today) {
|
||||
// Migration for existing states without score
|
||||
if (parsed.score === undefined) {
|
||||
parsed.score = INITIAL_SCORE;
|
||||
parsed.replayCount = 0;
|
||||
parsed.skipCount = 0;
|
||||
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
||||
parsed.yearGuessed = false;
|
||||
|
||||
// Retroactively deduct points for existing guesses if possible,
|
||||
// but simpler to just start at 90 for active games to avoid confusion
|
||||
|
||||
// Always recompute genreKey to ensure it's current
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
|
||||
// Try to load from backend first
|
||||
const loadFromBackend = async () => {
|
||||
try {
|
||||
const backendState = await loadPlayerState(currentGenreKey);
|
||||
|
||||
if (backendState) {
|
||||
const { gameState: loadedState, statistics: loadedStats } = backendState;
|
||||
|
||||
// Check if the loaded state is for today
|
||||
if (loadedState.date === today) {
|
||||
setGameState(loadedState);
|
||||
setStatistics(loadedStats);
|
||||
return; // Successfully loaded from backend
|
||||
} else {
|
||||
// State is for a different day - create new state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
setStatistics(loadedStats); // Keep statistics across days
|
||||
// Save new state to backend
|
||||
await savePlayerState(currentGenreKey, newState, loadedStats);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No backend state found - create new state
|
||||
// This is the normal case for first-time players or new genre
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
const newStats = createNewStatistics();
|
||||
setStatistics(newStats);
|
||||
// Save to backend for cross-domain sync
|
||||
await savePlayerState(currentGenreKey, newState, newStats);
|
||||
return;
|
||||
}
|
||||
setGameState(parsed as GameState);
|
||||
} else {
|
||||
// New day
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to load from backend:', error);
|
||||
|
||||
// On error, create new state and try to save to backend
|
||||
// This handles network errors gracefully
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
const newStats = createNewStatistics();
|
||||
setStatistics(newStats);
|
||||
// Try to save to backend (may fail, but we try)
|
||||
try {
|
||||
await savePlayerState(currentGenreKey, newState, newStats);
|
||||
} catch (saveError) {
|
||||
console.error('[gameState] Failed to save new state to backend:', saveError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
};
|
||||
|
||||
loadFromBackend();
|
||||
}, [genre, isSpecial]); // Re-run when genre or isSpecial changes
|
||||
|
||||
// Load statistics
|
||||
const statsKey = getStatsKey();
|
||||
const storedStats = localStorage.getItem(statsKey);
|
||||
if (storedStats) {
|
||||
const parsedStats = JSON.parse(storedStats);
|
||||
// Migration for existing stats without solvedIn7
|
||||
if (parsedStats.solvedIn7 === undefined) {
|
||||
parsedStats.solvedIn7 = 0;
|
||||
}
|
||||
setStatistics(parsedStats);
|
||||
} else {
|
||||
const newStats: Statistics = {
|
||||
solvedIn1: 0,
|
||||
solvedIn2: 0,
|
||||
solvedIn3: 0,
|
||||
solvedIn4: 0,
|
||||
solvedIn5: 0,
|
||||
solvedIn6: 0,
|
||||
solvedIn7: 0,
|
||||
failed: 0,
|
||||
};
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
||||
}
|
||||
}, [genre]); // Re-run when genre changes
|
||||
|
||||
const saveState = (newState: GameState) => {
|
||||
const saveState = async (newState: GameState) => {
|
||||
setGameState(newState);
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
||||
|
||||
// Save to backend only
|
||||
if (statistics) {
|
||||
try {
|
||||
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
await savePlayerState(currentGenreKey, newState, statistics);
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to save to backend:', error);
|
||||
// No fallback - backend is required for cross-domain sync
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
||||
const updateStatistics = async (attempts: number, solved: boolean) => {
|
||||
if (!statistics) return;
|
||||
|
||||
const newStats = { ...statistics };
|
||||
@@ -139,11 +164,24 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
}
|
||||
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
||||
|
||||
// Save to backend only
|
||||
if (gameState) {
|
||||
try {
|
||||
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
await savePlayerState(currentGenreKey, gameState, newStats);
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to save statistics to backend:', error);
|
||||
// No fallback - backend is required for cross-domain sync
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addGuess = (guess: string, correct: boolean) => {
|
||||
if (!gameState) return;
|
||||
// Prevent adding guesses if already solved or failed
|
||||
if (gameState.isSolved || gameState.isFailed) return;
|
||||
|
||||
const newGuesses = [...gameState.guesses, guess];
|
||||
const isSolved = correct;
|
||||
@@ -162,6 +200,9 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
} else {
|
||||
newScore -= 3;
|
||||
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
||||
// Additional penalty for track extension (unlock steps)
|
||||
newScore -= 5;
|
||||
newBreakdown.push({ value: -5, reason: 'Track extension' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
lib/i18n.ts
Normal file
41
lib/i18n.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type LocalizedString = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export function getLocalizedValue(
|
||||
value: any,
|
||||
locale: string,
|
||||
fallback: string = ''
|
||||
): string {
|
||||
if (!value) return fallback;
|
||||
|
||||
// If it's already a string, return it (backward compatibility or simple values)
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
// If it's an object, try to get the requested locale
|
||||
if (typeof value === 'object') {
|
||||
if (value[locale]) return value[locale];
|
||||
|
||||
// Fallback to 'en'
|
||||
if (value['en']) return value['en'];
|
||||
|
||||
// Fallback to 'de'
|
||||
if (value['de']) return value['de'];
|
||||
|
||||
// Fallback to first key
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 0) return value[keys[0]];
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function createLocalizedObject(
|
||||
de: string,
|
||||
en?: string
|
||||
): LocalizedString {
|
||||
return {
|
||||
de: de.trim(),
|
||||
en: (en || de).trim()
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user