Compare commits

...

157 Commits

Author SHA1 Message Date
Hördle Bot
17856ef09b Bump version to 0.1.6.28 2025-12-07 10:11:06 +01:00
Hördle Bot
fb833a7976 Fix: Waveform Editor lädt nicht für Titel ohne vollständige Song-Daten
- Filtere Songs ohne vollständige Song-Daten (song, filename) in CurateSpecialEditor
- Füge defensive Prüfungen hinzu bevor WaveformEditor gerendert wird
- Filtere unvollständige Songs bereits auf API-Ebene in curator/specials/[id]
- Verhindert Fehler wenn Songs ohne filename oder song-Objekt geladen werden
2025-12-07 10:07:43 +01:00
Hördle Bot
a4e61de53f chore: bump version to 0.1.6.27 2025-12-06 21:58:29 +01:00
Hördle Bot
73c1c1cf89 fix: restore accidentally deleted admin specials editor page 2025-12-06 21:58:27 +01:00
Hördle Bot
83e1281079 fix: restore deleted curator implementation files 2025-12-06 21:50:59 +01:00
Hördle Bot
2e1f1e599b chore: bump version to 0.1.6.26 2025-12-06 15:39:15 +01:00
Hördle Bot
71c4e2509f feat(admin): add danger zone buttons for resetting ratings and activations
- Added reset all user ratings button to admin danger zone
- Added reset all activations button to admin danger zone
- Created API endpoints: /api/admin/reset-ratings and /api/admin/reset-activations
- Removed old non-localized routes: /app/admin, /app/curator
- Removed unused page.module.css
- All admin functionality now uses localized routes (/[locale]/admin)
2025-12-06 15:38:46 +01:00
Hördle Bot
9cef1c78d3 ... 2025-12-06 14:26:52 +01:00
Hördle Bot
6741eeb7fa feat: Album-Cover-Anzeige in Titelliste mit Tooltip hinzugefügt
- Neue Spalte 'Cover' in der Curator-Titelliste zeigt an, ob ein Album-Cover vorhanden ist
- Tooltip zeigt das Cover-Bild beim Hovern über die Cover-Spalte
- Übersetzungen für DE und EN hinzugefügt
2025-12-06 14:24:00 +01:00
Hördle Bot
71b8e98f23 feat: add hidden flag to specials 2025-12-06 01:35:01 +01:00
Hördle Bot
bc2c0bad59 Bump version to 0.1.6.24 2025-12-06 00:36:02 +01:00
Hördle Bot
812d6ff10d Add timeline display below waveform in waveform editor 2025-12-06 00:36:00 +01:00
Hördle Bot
aed300b1bb Bump version to 0.1.6.23 2025-12-05 23:55:24 +01:00
Hördle Bot
e93b3b9096 Keep playback cursor visible when pausing in waveform editor 2025-12-05 23:55:21 +01:00
Hördle Bot
cdd2ff15d5 Bump version to 0.1.6.22 2025-12-05 22:25:41 +01:00
Hördle Bot
adcfbfa811 Fix pause functionality for waveform editor playback buttons 2025-12-05 22:25:39 +01:00
Hördle Bot
0cdfe90476 Bump version to 0.1.6.21 2025-12-05 22:06:44 +01:00
Hördle Bot
1715ca02ed Remove duplicate back button from curator special editor 2025-12-05 22:06:42 +01:00
Hördle Bot
ece3991d37 Bump version to 0.1.6.20 2025-12-05 21:57:36 +01:00
Hördle Bot
fa3b64f490 Add 'Play Full Title' button to waveform editor 2025-12-05 21:57:34 +01:00
Hördle Bot
fa6f1097dd Bump version to 0.1.6.19 2025-12-05 21:48:00 +01:00
Hördle Bot
d2ec0119ce Fix waveform editor: show end marker for last segment and fix play full section stop functionality 2025-12-05 21:47:57 +01:00
Hördle Bot
8914c552cd Bump version to 0.1.6.18 2025-12-05 21:33:44 +01:00
Hördle Bot
d816422419 Update song list start time after saving changes in waveform editor 2025-12-05 21:33:41 +01:00
Hördle Bot
da777ffcf3 Bump version to 0.1.6.17 2025-12-05 20:56:29 +01:00
Hördle Bot
0d806daf66 Add JSON validation for unlock steps in admin specials management with tooltip error display 2025-12-05 20:56:27 +01:00
Hördle Bot
616cfec3e7 Bump version to 0.1.6.16 2025-12-05 20:41:40 +01:00
Hördle Bot
ac12e45393 Fix curator specials page: resolve redirect loop and add missing translations 2025-12-05 20:41:38 +01:00
Hördle Bot
223eb62973 Bump version to 0.1.6.15 2025-12-05 20:13:31 +01:00
Hördle Bot
dc4bdd36c7 Fix textarea alignment: add box-sizing border-box to prevent overflow 2025-12-05 20:13:27 +01:00
Hördle Bot
136f881252 Bump version to 0.1.6.14 2025-12-05 18:43:15 +01:00
Hördle Bot
fd11048f2c Fix daily puzzle selection: always select from songs with minimum activations 2025-12-05 18:43:09 +01:00
Hördle Bot
c1b448639e Add logo generation scripts and favicon base image 2025-12-05 12:26:54 +01:00
Hördle Bot
97021f016b Add logo files (SVG and PNG) with white background and hördle.de text 2025-12-05 12:26:15 +01:00
Hördle Bot
1991cbd93f Bump version to 0.1.6.13 2025-12-05 11:33:44 +01:00
Hördle Bot
c28c9fe8f0 Fix: Verbesserte Erkennung von umformulierten Nachrichten - nur inhaltliche Änderungen werden erkannt 2025-12-05 11:33:39 +01:00
Hördle Bot
803713dea7 Bump version to 0.1.6.12 2025-12-05 11:20:08 +01:00
Hördle Bot
0e6eba64d9 Security: Update Next.js to 16.0.7 to fix CVE-2025-55182 (React2Shell RCE vulnerability) 2025-12-05 11:18:33 +01:00
Hördle Bot
576b486caf Bump version to 0.1.6.11 2025-12-05 10:55:22 +01:00
Hördle Bot
d8f69631b5 Fix: AI-Nachrichtenverarbeitung - Nur bei geänderten Nachrichten anzeigen, Checkbox für Einverständnis hinzufügen 2025-12-05 10:55:18 +01:00
Hördle Bot
dbcdaf9278 Enhance deployment script: add cleanup for build cache alongside old images 2025-12-04 21:38:04 +01:00
Hördle Bot
2e93d09236 Bump version to 0.1.6.10 2025-12-04 21:26:20 +01:00
Hördle Bot
a1fe62f132 Fix: Verwende Punycode-Domain (xn--hrdle-jua.de) in Share-Nachricht für hördle.de-Benutzer 2025-12-04 21:26:16 +01:00
Hördle Bot
e49c6acc99 Bump version to 0.1.6.9 2025-12-04 20:39:49 +01:00
Hördle Bot
96cc9db7d6 Fix: hasLost/hasWon korrekt beim Reload initialisieren 2025-12-04 20:39:43 +01:00
Hördle Bot
ebc482dc87 Bump version to 0.1.6.8 2025-12-04 20:13:23 +01:00
Hördle Bot
88dd86c344 Fix: Nur wirklich problematische Nachrichten umschreiben 2025-12-04 20:13:20 +01:00
Hördle Bot
623e8b9b82 Update help tooltip for assigning specials in curator upload to improve clarity 2025-12-04 14:45:39 +01:00
Hördle Bot
286ac2d28a Add help tooltip for assigning specials in curator upload 2025-12-04 14:38:33 +01:00
Hördle Bot
c02d3df7ed Load Restic credentials from ~/.restic-env in backup/restore scripts 2025-12-04 13:49:55 +01:00
Hördle Bot
702f47b7e5 Bump version to v0.1.6.7 2025-12-04 13:40:38 +01:00
Hördle Bot
86f3349f80 Fix duplicate toggleUploadSpecial definition in curator client 2025-12-04 13:40:23 +01:00
Hördle Bot
bdb74fb462 Bump version to v0.1.6.6 2025-12-04 13:36:40 +01:00
Hördle Bot
66c0071257 Allow curators to assign specials on upload and update help text 2025-12-04 13:36:24 +01:00
Hördle Bot
76f14087fd Bump version to v0.1.6.5 2025-12-04 13:27:48 +01:00
Hördle Bot
b1ab5bd633 Fix build by redirecting /curator/specials to localized route 2025-12-04 13:27:36 +01:00
Hördle Bot
51c62e7763 Bump version to v0.1.6.4 2025-12-04 13:19:58 +01:00
Hördle Bot
de6eadfe62 Respect MP3 release year when fetching iTunes metadata 2025-12-04 13:19:06 +01:00
Hördle Bot
b033c3a1bc Document and explain curator special curation flow 2025-12-04 13:08:07 +01:00
Hördle Bot
4b7121271a Remove curated specials button from localized admin page 2025-12-04 13:02:11 +01:00
Hördle Bot
12cc81905e Tighten admin specials handling and remove obsolete curate button 2025-12-04 12:31:49 +01:00
Hördle Bot
b46e9e3882 Add curator special curation flow and shared editor 2025-12-04 12:27:08 +01:00
Hördle Bot
332688d693 feat: Enhance player comment features with AI rewriting and collapsible form 2025-12-04 09:42:01 +01:00
Hördle Bot
a725694519 chore: Version auf v0.1.6.3 erhöht 2025-12-04 08:59:53 +01:00
Hördle Bot
cdb9803b40 Merge branch 'feature/curator-message-improvements' 2025-12-04 08:56:05 +01:00
Hördle Bot
7db4e26b2c feat: Implement AI-powered comment rewriting and a collapsible comment form for user feedback. 2025-12-04 08:54:25 +01:00
Hördle Bot
b204a35628 chore: Version auf v0.1.6.2 erhöht 2025-12-04 01:17:10 +01:00
Hördle Bot
c62f8f91e5 Merge branch 'curator-help' 2025-12-04 01:16:10 +01:00
Hördle Bot
6fbb3f4718 feat: Fragezeichen durch Info-Icon (ℹ) ersetzt
- HelpTooltip-Komponente verwendet jetzt ℹ statt ?
- Help-Button im Header verwendet jetzt ℹ statt 
- Konsistenteres Design mit Informations-Icon
2025-12-04 01:15:31 +01:00
Hördle Bot
5136c3add1 fix: Button-Höhen angeglichen
- Help- und Logout-Button haben jetzt identische Styles
- Gleiche lineHeight, boxSizing und fontFamily für konsistente Höhe
- Beide Buttons verwenden inline-flex mit center alignment
2025-12-04 01:14:01 +01:00
Hördle Bot
c250b5fff9 fix: Locale-Prefix in Links entfernt
- Link-Komponente aus @/lib/navigation fügt Locale automatisch hinzu
- Links verwenden jetzt relative Pfade ohne Locale-Prefix
- Behebt 404-Fehler bei /en/en/curator/help
2025-12-04 01:11:56 +01:00
Hördle Bot
4074cdfe00 fix: Modal-Titel in HelpTooltip übersetzt
- Modal-Titel verwendet jetzt Übersetzung (Hilfe/Help)
- Browser-Tooltip entfernt (nur noch custom Tooltip)
- useTranslations in HelpTooltip-Komponente integriert
2025-12-04 01:09:48 +01:00
Hördle Bot
65425ac15c feat: Curator-Hilfe-System implementiert
- Hilfe-Seite /curator/help mit vollständiger Dokumentation (de/en)
- HelpTooltip-Komponente mit Hover- und Click-Modi
- Tooltips bei allen wichtigen Dashboard-Bereichen:
  * Dashboard-Übersicht
  * Upload-Bereich & Genre-Zuweisung
  * Track-Liste (Suche, Filter, Batch-Edit)
  * Kommentar-Verwaltung
- Prominenter Hilfe-Button im Header
- Umfassende Übersetzungen für alle Hilfe-Texte
- Fix: TypeScript-Fehler in batch route behoben
- Fix: Doppelter Browser-Tooltip entfernt (nur noch custom Tooltip)
2025-12-04 01:07:45 +01:00
Hördle Bot
7879b63498 fix: TypeScript-Fehler in batch route korrigiert
- Verwende lokale Variable curatorAssignments statt nullable assignments
- TypeScript erkennt jetzt korrekt, dass die Variable nicht null ist
2025-12-04 00:57:02 +01:00
Hördle Bot
91ebaa0e44 fix: TypeScript-Fehler in batch route behoben
- Non-Null-Assertion für assignments hinzugefügt
- assignments ist innerhalb des curator-Blocks garantiert nicht null
2025-12-04 00:52:16 +01:00
Hördle Bot
a61caa2d13 feat: README.md um Batch-Edit-Funktionalität für Kuratoren erweitert
- Beschreibung der neuen Batch-Edit-Optionen hinzugefügt, einschließlich der Möglichkeit, mehrere Titel gleichzeitig zu bearbeiten.
- Details zu Genre/Special Toggle, Artist-Änderung und Exclude Global Flag für globale Kuratoren ergänzt.
2025-12-04 00:45:08 +01:00
Hördle Bot
52a15b7504 chore: Version auf v0.1.6.1 erhöht 2025-12-04 00:43:27 +01:00
Hördle Bot
00160d9602 feat: Batch-Edit Übersetzungen hinzugefügt
- Englische und deutsche Übersetzungen für alle Batch-Edit-Funktionen
- Keys für Toolbar, Buttons, Meldungen und Fehlerbehandlung
- Unterstützt Genre/Special Toggle, Artist-Änderung und Exclude Global
2025-12-04 00:42:19 +01:00
Hördle Bot
296a227d22 feat: Batch-Edit-Funktionalität für Curator Track-Liste
- Neue API-Route /api/songs/batch für Batch-Updates
- Checkbox-Spalte in Tabelle mit Select-All-Funktionalität
- Batch-Edit-Toolbar mit Genre/Special Toggle, Artist-Änderung und Exclude Global Flag
- Visuelle Hervorhebung ausgewählter Zeilen
- Unterstützt Toggle-Modus für Genres/Specials (hinzufügen/entfernen)
- Validiert Kurator-Berechtigungen für jeden Song
- Transaktionsbasierte Updates für Konsistenz
2025-12-04 00:38:08 +01:00
Hördle Bot
50ca51b143 Enhance special ID handling in song API routes
- Updated logic to prioritize specialId and special.id for SpecialSong objects.
- Added comments for clarity on ID usage and conditions for retrieving special IDs.
- Modified API response to include related special details for better data integrity.
2025-12-04 00:24:14 +01:00
Hördle Bot
afe6e12afc Implement special selection feature in CuratorPageClient
- Added a new section for curators to select specials associated with their account.
- Introduced checkboxes for editing special selections, allowing for dynamic updates.
- Updated the display logic for specials to differentiate between selected and unselected items.
2025-12-04 00:14:27 +01:00
Hördle Bot
91b12ad859 Erweitere README.md um Kuratoren-System und Analytics-Funktionen
- Einführung eines Kuratoren-Managements mit separaten Accounts, Genre- und Special-Zuweisungen.
- Kuratoren können Songs verwalten und Spieler-Kommentare einsehen.
- Integration von Plausible Analytics für anonyme Nutzungsstatistiken und automatisches Domain-Tracking.
- Aktualisierung der Anweisungen für Kurator-Zugang und -Funktionen.
2025-12-03 23:30:31 +01:00
Hördle Bot
d2548c2870 Bump version to 0.1.6.0 2025-12-03 23:17:56 +01:00
Hördle Bot
40d6ea75f0 Behebe zwei Bugs im Kurator-Kommentar-System
Bug 1: Special-Kuratoren für Special-Puzzles berücksichtigen
- Kommentare zu Special-Puzzles werden jetzt korrekt an Special-Kuratoren geroutet
- Logik erweitert: Prüft zuerst puzzle.specialId, dann genreId, dann global
- Special-Kuratoren werden über CuratorSpecial abgerufen

Bug 2: Prisma Schema konsistent mit Migration
- onDelete: SetNull zur genre-Relation in CuratorComment hinzugefügt
- Entspricht jetzt dem Foreign Key Constraint in der Migration
2025-12-03 23:15:18 +01:00
Hördle Bot
0054facbe7 Füge Puzzle-Kontext zu Kommentaren hinzu und verbessere Sichtbarkeit neuer Kommentare
- Puzzle-Kontext: Hördle #, Genre/Special, Titel werden jetzt angezeigt
- API erweitert: puzzleNumber wird berechnet, special-Informationen inkludiert
- Badge für neue Kommentare: zeigt Anzahl ungelesener Kommentare
- Verbesserte Kommentar-Anzeige mit vollständigem Rätsel-Kontext
- UI-Anpassungen: nur Badge für neue Kommentare, keine übermäßige Hervorhebung
2025-12-03 23:09:45 +01:00
Hördle Bot
95bcf9ed1e Füge Archivierungs-Funktion für Kommentare hinzu und fixe initiales Laden
- Archivierungs-Funktionalität: Kuratoren können Kommentare archivieren
- archived-Flag in CuratorCommentRecipient hinzugefügt
- API-Route für Archivieren: /api/curator-comments/[id]/archive
- Kommentare werden beim initialen Laden automatisch abgerufen
- Archivierte Kommentare werden nicht mehr in der Liste angezeigt
- Archivieren-Button in der UI hinzugefügt
- Migration für archived-Feld
- Übersetzungen für Archivierung (DE/EN)
2025-12-03 22:57:28 +01:00
Hördle Bot
08fedf9881 Verschiebe Kommentare-Sektion ganz nach oben in Kuratoren-Seite 2025-12-03 22:47:40 +01:00
Hördle Bot
cd564b5d8c Implementiere Kurator-Kommentar-System
- Benutzer können nach Rätsel-Abschluss optional Nachricht an Kuratoren senden
- Kommentare werden in Datenbank gespeichert und in /curator angezeigt
- Neue Datenbank-Modelle: CuratorComment und CuratorCommentRecipient
- API-Routen für Kommentar-Versand, Abfrage und Markierung als gelesen
- Rate-Limiting: 1 Kommentar pro Spieler pro Rätsel (persistent in DB)
- Sicherheitsschutz: PlayerIdentifier-Validierung, Puzzle-Validierung
- Automatische Zuordnung zu Kuratoren (Genre-basiert + globale Kuratoren)
- Frontend: Kommentar-Formular in Game-Komponente
- Frontend: Kommentare-Anzeige in Kuratoren-Seite mit Markierung als gelesen
- Übersetzungen für DE und EN hinzugefügt
2025-12-03 22:46:02 +01:00
Hördle Bot
863539a5e9 Add Restic backup to remote repository in deploy script 2025-12-03 19:19:11 +01:00
Hördle Bot
2fa8aa0042 Bump version to v0.1.5.2 2025-12-03 18:36:47 +01:00
Hördle Bot
8ecf430bf5 Wrap song updates and deletes in database transactions for consistency 2025-12-03 18:36:32 +01:00
Hördle Bot
71abb7c322 Bump version to v0.1.5.1 2025-12-03 17:34:40 +01:00
Hördle Bot
b730c6637a Fix random song selection bias in daily puzzle generation 2025-12-03 17:34:23 +01:00
Hördle Bot
6e93529bc3 Add backup metadata and restore script for full DB rollback 2025-12-03 16:25:50 +01:00
Hördle Bot
990e1927e9 Curator: Client-Komponente ausgelagert, Server-Wrapper für stabilen Build 2025-12-03 15:28:17 +01:00
Hördle Bot
d7fee047c2 Deploy: shallow fetch + dynamische /curator-Seite für Docker-Build 2025-12-03 15:16:38 +01:00
Hördle Bot
28d14ff099 chore: bump version to v0.1.5.0 2025-12-03 15:12:50 +01:00
Hördle Bot
b1493b44bf Game: Share-Button unter Rating platziert und kurz erläutert 2025-12-03 15:03:32 +01:00
Hördle Bot
b8a803b76e Songs-API: robuste Behandlung möglicher verwaister SpecialSong-Relationen 2025-12-03 14:56:40 +01:00
Hördle Bot
e2bdf0fc88 Game: Attempt-Anzeige nach Rätsel-Ende nicht auf nächsten Versuch springen lassen 2025-12-03 14:09:31 +01:00
Hördle Bot
2cb9af8d2b Game: öffentliche Song-Liste für GuessInput statt geschütztem /api/songs 2025-12-03 14:06:32 +01:00
Hördle Bot
d6ad01b00e Curator-UI: sichere Optional-Chains für Genre-Filter 2025-12-03 13:46:58 +01:00
Hördle Bot
693817b18c Curator-Song-Update: Genre-Zuordnungen auch bei leerem Array korrekt übernehmen 2025-12-03 13:42:02 +01:00
Hördle Bot
41336e3af3 Curators API: aussagekräftige Fehler bei doppelten Usernames (P2002) 2025-12-03 13:37:59 +01:00
Hördle Bot
d7ec691469 Curator: Optional Chaining für Genre/Special-Filter abgesichert 2025-12-03 13:31:38 +01:00
Hördle Bot
5e1700712e Fix: Kuratoren-Scope für Specials & Audio-Playback im Curator-Dashboard 2025-12-03 13:25:43 +01:00
Hördle Bot
f691384a34 API: Auth & Scope für Song-GET, Kommentar für Kurator-Wrapper 2025-12-03 13:17:31 +01:00
Hördle Bot
f0d75c591a Admin: Validierung für Kuratoren-Passwort bei Neuanlage 2025-12-03 13:13:02 +01:00
Hördle Bot
1f34d5813e Fix: Kuratoren-Berechtigungscheck für Specials vereinheitlicht 2025-12-03 13:11:12 +01:00
Hördle Bot
33f8080aa8 Curator: Lokalisierung und einstellbare Paginierung 2025-12-03 13:09:20 +01:00
Hördle Bot
8a102afc0e Admin: Song Library und Upload entfernt 2025-12-03 12:59:26 +01:00
Hördle Bot
38148ace8d Kuratoren-Accounts und Anpassungen im Admin- und Kuratoren-Dashboard 2025-12-03 12:52:38 +01:00
Hördle Bot
49e98ade3c Update credits text in configuration to include a heart emoji for improved sentiment 2025-12-02 19:36:47 +01:00
Hördle Bot
397839cc1f Update credits text in configuration to enhance clarity 2025-12-02 19:29:55 +01:00
Hördle Bot
3fe805129b Change deploy-remote.sh file permissions to make it executable 2025-12-02 15:03:03 +01:00
Hördle Bot
bf9a49a9ac Update SSH password environment variable in remote deployment script 2025-12-02 14:56:36 +01:00
Hördle Bot
9b89cbf8ed Handle temporary SQLite DB during Docker build to prevent errors in sitemap generation 2025-12-02 14:55:50 +01:00
Hördle Bot
7f33e98fb5 Update donation note in costs section and increment version to v0.1.4.12 2025-12-02 14:54:26 +01:00
Hördle Bot
72f8b99092 Adjust costs section donation note and bump version to v0.1.4.11 2025-12-02 14:50:02 +01:00
Hördle Bot
e60daa511b Add donation note for political beauty and bump version to v0.1.4.10 2025-12-02 14:42:46 +01:00
Hördle Bot
19706abacb Bump version to v0.1.4.9 2025-12-02 14:26:17 +01:00
Hördle Bot
170e7b5402 Store political statements in database 2025-12-02 14:14:53 +01:00
Hördle Bot
ade1043c3c chore: Update .gitignore to include new script and documentation files 2025-12-02 14:11:11 +01:00
Hördle Bot
d69af49e24 Bump version to v0.1.4.8 2025-12-02 13:56:45 +01:00
Hördle Bot
63687524e7 Merge branch 'partnerpuzzles' 2025-12-02 13:56:10 +01:00
Hördle Bot
0246cb58ee Include political statements feature files 2025-12-02 13:30:23 +01:00
Hördle Bot
d76aa9f4e9 Bump version to v0.1.4.7 2025-12-02 13:28:33 +01:00
Hördle Bot
28afaf598b Bump version to v0.1.4.6 2025-12-02 11:10:13 +01:00
Hördle Bot
8239753911 feat: Enhance Game component with extra puzzles feature
- Introduce requiredDailyKeys to track daily puzzle completion across genres.
- Implement logic to show an ExtraPuzzlesPopover when all daily puzzles are completed.
- Add localized messages for extra puzzles in both English and German.
- Update GenrePage and Home components to pass requiredDailyKeys to the Game component.
2025-12-02 10:59:22 +01:00
Hördle Bot
0bfcf0737e Bump version to v0.1.4.5 2025-12-02 10:00:42 +01:00
Hördle Bot
5409196008 fix: Update domain handling for sharing URLs
- Modify currentHost logic to always share "hördle.de" instead of the Punycode variant when applicable.
- Ensure compatibility with both hoerdle.de and hördle.de for improved user experience.
2025-12-02 09:40:15 +01:00
Hördle Bot
a59f6f747e chore: Bump version to 0.1.4.4 2025-12-02 01:51:43 +01:00
Hördle Bot
dc763c88a3 feat: Add device-specific isolation for player IDs
- Add device ID generation (unique per device, stored in localStorage)
- Extend player ID format to: {basePlayerId}:{deviceId}
- Enable cross-domain sync on same device while keeping devices isolated
- Update backend APIs to support new player ID format
- Maintain backward compatibility with legacy UUID format

This allows:
- Each device (Desktop, Android, iOS) to have separate game states
- Cross-domain sync still works on the same device (hoerdle.de ↔ hördle.de)
- Easier debugging with visible device ID in player identifier
2025-12-02 01:49:45 +01:00
Hördle Bot
1613bf0dda chore: Bump version to 0.1.4.3 2025-12-02 01:28:34 +01:00
Hördle Bot
b872e87b50 feat: Add -5 points penalty for track extension on wrong guesses
- Add -5 points penalty for track extension (unlock steps) on wrong guesses
- Wrong guess now costs -8 points total (-3 for wrong + -5 for extension)
- Skip remains at -5 points (no additional penalty)
- Update documentation (README.md, SCORING_OPTIONS.md)
- Add SCORING_OPTIONS.md with detailed scoring system analysis
2025-12-02 01:28:27 +01:00
Hördle Bot
87c1ee63ec feat: Add tooltip to star rating and support section updates
- Add tooltip to star rating component encouraging users to help curators
- Add curator application information to support section
- Add bug report email link to support section
- All changes localized (de/en)
2025-12-02 01:00:08 +01:00
Hördle Bot
8c57e938e8 chore: Bump version to 0.1.4.2 2025-12-01 23:59:22 +01:00
Hördle Bot
9eb07ee8d5 refactor: Ensure genreKey is always recomputed before use
- Recompute genreKey inside useEffect and save functions to ensure current values
- Prevents potential closure issues with stale genreKey values
- Improves code quality and prevents future bugs
2025-12-01 23:58:11 +01:00
Hördle Bot
3eb6c7f5cf chore: Remove privacy legal advice disclaimer from about page
- Remove privacyNoLegalAdvice text from about page
- Remove privacyNoLegalAdvice from German and English translations
2025-12-01 22:12:49 +01:00
Hördle Bot
2846afb6f7 feat: Remove localStorage for game states and implement cross-domain player ID sync
- Remove localStorage for game states and statistics (backend only)
- Add API route to suggest player ID based on recently updated states
- Add async player ID lookup that finds existing IDs across domains
- When visiting a new domain, automatically find and use existing player ID
- Enables cross-domain synchronization between hoerdle.de and hördle.de
2025-12-01 20:37:47 +01:00
Hördle Bot
27fa689b18 fix: Prevent replaying already solved puzzles across domains
- Add checks in handleGuess, handleSkip, and handleGiveUp to prevent actions on solved/failed puzzles
- Add protection in addGuess to prevent adding guesses to solved puzzles
- Fix and simplify backend state loading logic
- Ensure solved puzzles cannot be replayed when switching domains
2025-12-01 20:22:28 +01:00
Hördle Bot
61846a6982 feat: Add backend storage for cross-domain player state synchronization
- Add PlayerState model to database schema for storing game states
- Create player identifier system (UUID-based) for cross-domain sync
- Implement API endpoints for loading/saving player states
- Refactor gameState hook to use backend storage with localStorage fallback
- Support synchronization between hoerdle.de and hördle.de
- Migration automatically runs on Docker container start
2025-12-01 20:09:54 +01:00
Hördle Bot
bba6b9ef31 fix: Use current domain in share URL instead of static config.domain
- Share URL now uses window.location.hostname to support both hoerdle.de and hördle.de
- Protocol is automatically detected from window.location.protocol
- Fixes issue where share URL always used hoerdle.de even when accessed via hördle.de
2025-12-01 19:53:26 +01:00
Hördle Bot
a8867ac42e feat: Add dynamic Open Graph image generation with correct aspect ratio
- Create /api/og-image endpoint that generates SVG with 1.91:1 ratio (1200x630px)
- Prevents logo cropping on Facebook and Twitter
- Uses safe padding (150px) to ensure content is never cut off
- Update default OG image to use dynamic endpoint
- Add SEO testing documentation
2025-12-01 19:44:48 +01:00
Hördle Bot
9006b208af chore: Bump version to v0.1.4.1 2025-12-01 19:31:00 +01:00
Hördle Bot
20c8ad7eaf feat: Add comprehensive SEO implementation
- Add robots.txt with admin/API blocking
- Add dynamic sitemap.xml with static pages and genre pages
- Implement full meta tags (Open Graph, Twitter Cards, Canonical, Alternates)
- Add SEO helper functions for domain detection and URL generation
- Add generateMetadata to all pages (homepage, about, genre, special)
- Support automatic domain detection for hoerdle.de and hördle.de
- Add SEO configuration to lib/config.ts
2025-12-01 19:28:43 +01:00
Hördle Bot
03129a5611 Remove imprint disclaimer from About page and localization files for German and English. 2025-12-01 19:13:33 +01:00
Hördle Bot
fd8f4adcc0 Bump version to 0.1.4 2025-12-01 18:51:31 +01:00
Hördle Bot
23997ccc3a Refactor: Plausible-Konfiguration - automatisches Domain-Tracking und NEXT_PUBLIC_PLAUSIBLE_DOMAIN entfernt
- Domain wird automatisch aus Request-Header erkannt (hoerdle.de / hördle.de)
- NEXT_PUBLIC_PLAUSIBLE_DOMAIN komplett entfernt (nicht mehr benötigt)
- CSP in proxy.ts konfigurierbar gemacht
- twitterHandle entfernt (wurde nicht verwendet)
- Dokumentation aktualisiert
2025-12-01 18:51:15 +01:00
Hördle Bot
85bdbf795c Refactor: Plausible-Konfiguration aktualisiert und twitterHandle entfernt
- Defaults auf neue Domains aktualisiert (hoerdle.de statt hoerdle.elpatron.me)
- CSP in proxy.ts konfigurierbar gemacht (liest Plausible-URL aus Umgebungsvariablen)
- twitterHandle entfernt (wurde nirgendwo verwendet)
- Dokumentation aktualisiert
2025-12-01 18:29:09 +01:00
Hördle Bot
ac0bb02ba0 Bump version to 0.1.3 2025-12-01 18:18:17 +01:00
Hördle Bot
63269c2600 Change: Standard-Sprache von Deutsch auf Englisch geändert
- defaultLocale in proxy.ts auf 'en' geändert
- Fallback in i18n/request.ts auf 'en' geändert
- Fallback-Reihenfolge in lib/i18n.ts angepasst (en vor de)
- Share-URL-Logik in Game.tsx angepasst
- Dokumentation aktualisiert
2025-12-01 18:18:11 +01:00
Hördle Bot
17a39d677d Update: package-lock.json aktualisiert für baseline-browser-mapping 2025-12-01 18:15:22 +01:00
Hördle Bot
1ff0787e4e Bump version to 0.1.2 2025-12-01 18:11:39 +01:00
Hördle Bot
ed5f02bdec Fix: 'Kurieren' zu 'Kuratieren' korrigiert im Admin-Dashboard 2025-12-01 18:11:24 +01:00
Hördle Bot
e3a09864a6 Refactor: Dokumentation nach docs/ verschoben
- Alle Markdown-Dateien (außer README.md) nach docs/ verschoben
- Referenzen in README.md aktualisiert
- /docs zu .dockerignore hinzugefügt
2025-12-01 17:58:32 +01:00
Hördle Bot
107739ade9 Fix: Update Dockerfile to optimize build process and reduce image size
- Refactored the Dockerfile to streamline the build process.
- Removed unnecessary layers and combined commands for efficiency.
- Improved caching strategy to enhance build performance.
2025-12-01 17:54:12 +01:00
117 changed files with 11707 additions and 4157 deletions

1
.cursor/commands/bump.md Normal file
View File

@@ -0,0 +1 @@
teste den build (npm run build), anschließend commit, dann bump zum nächsten patchlevel, git tag und sync

View File

@@ -50,6 +50,7 @@ Dockerfile*
.dockerignore
# Documentation
/docs
*.md
!README.md

3
.gitignore vendored
View File

@@ -52,3 +52,6 @@ next-env.d.ts
.release-years-migrated
.covers-migrated
docker-compose.yml
scripts/scrape-bahn-expert-statements.js
docs/bahn-expert-statements.txt
/public/logos.zip

View File

@@ -38,6 +38,9 @@ RUN if [ -n "$APP_VERSION" ]; then \
# 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
@@ -46,8 +49,6 @@ RUN node_modules/.bin/prisma generate
ARG NEXT_PUBLIC_APP_NAME
ARG NEXT_PUBLIC_APP_DESCRIPTION
ARG NEXT_PUBLIC_DOMAIN
ARG NEXT_PUBLIC_TWITTER_HANDLE
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
ARG NEXT_PUBLIC_THEME_COLOR
ARG NEXT_PUBLIC_BACKGROUND_COLOR
@@ -60,8 +61,6 @@ ARG NEXT_PUBLIC_CREDITS_LINK_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
ENV NEXT_PUBLIC_TWITTER_HANDLE=$NEXT_PUBLIC_TWITTER_HANDLE
ENV NEXT_PUBLIC_PLAUSIBLE_DOMAIN=$NEXT_PUBLIC_PLAUSIBLE_DOMAIN
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR

View File

@@ -15,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,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Live-Vorschau beim Hovern über die Waveform.
- Playback-Cursor zeigt aktuelle Abspielposition.
- Einzelne Segmente zum Testen abspielen.
- Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung.
- **News & Announcements:**
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
@@ -51,23 +51,45 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **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](I18N.md)**
👉 **[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 (Deutsch) um
- 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)](WHITE_LABEL.md)**
👉 **[Anleitung zur Anpassung (White Label Guide)](docs/WHITE_LABEL.md)**
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
@@ -77,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)
@@ -115,13 +139,13 @@ 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` (leitet automatisch zu `/de` um).
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!](WHITE_LABEL.md#docker-deployment)**
👉 **[White Labeling mit Docker? Hier klicken!](docs/WHITE_LABEL.md#docker-deployment)**
1. **Vorbereitung:**
Kopiere die Beispiel-Konfiguration:
@@ -137,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
@@ -154,20 +179,33 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
- 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).

View File

@@ -6,6 +6,8 @@ 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';
@@ -15,6 +17,32 @@ 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);
@@ -59,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
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' }}>
@@ -128,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
)}
</div>
<NewsSection locale={locale} />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
</>
);
}

View File

@@ -1,10 +1,22 @@
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" });
@@ -51,11 +63,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
{t("imprintEmailLabel")}{" "}
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
</p>
<p
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
>
{t("imprintDisclaimer")}
</p>
</section>
<section style={{ marginBottom: "2rem" }}>
@@ -91,13 +98,27 @@ export default async function AboutPage({ params }: AboutPageProps) {
</p>
<p
style={{
marginBottom: "0.75rem",
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" }}>
@@ -199,6 +220,58 @@ export default async function AboutPage({ params }: AboutPageProps) {
</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" }}>
@@ -234,11 +307,6 @@ export default async function AboutPage({ params }: AboutPageProps) {
</ul>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
<p
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
>
{t("privacyNoLegalAdvice")}
</p>
</section>
<section style={{ marginBottom: "2rem" }}>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
'use client';
import SpecialEditorPage from '@/app/admin/specials/[id]/page';
export default SpecialEditorPage;

View File

@@ -0,0 +1,8 @@
'use client';
import CuratorHelpInner from '../../../curator/help/page';
export default function CuratorHelpPage() {
return <CuratorHelpInner />;
}

View 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 />;
}

View File

@@ -0,0 +1,7 @@
'use client';
import CuratorSpecialEditorPage from '@/app/curator/specials/[id]/page';
export default CuratorSpecialEditorPage;

View File

@@ -0,0 +1,9 @@
'use client';
import CuratorSpecialsClient from '@/app/curator/specials/CuratorSpecialsClient';
export default function CuratorSpecialsPage() {
return <CuratorSpecialsClient />;
}

View File

@@ -5,10 +5,13 @@ 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",
@@ -20,10 +23,10 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: config.appName,
description: config.appDescription,
};
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,
@@ -52,12 +55,32 @@ export default async function LocaleLayout({
// 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={config.plausibleDomain}
data-domain={plausibleDomain}
src={config.plausibleScriptSrc}
strategy="beforeInteractive"
/>
@@ -67,6 +90,7 @@ export default async function LocaleLayout({
{children}
<InstallPrompt />
<AppFooter />
<PoliticalStatementBanner />
</NextIntlClientProvider>
</body>
</html>

View File

@@ -7,15 +7,33 @@ 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: { locale: string };
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations('Home');
@@ -25,7 +43,9 @@ export default async function Home({
const genres = await prisma.genre.findMany({
where: { active: true },
});
const specials = await prisma.special.findMany();
const specials = await prisma.special.findMany({
where: { hidden: false },
});
// Sort in memory
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
@@ -43,6 +63,9 @@ export default async function Home({
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' }}>
@@ -131,7 +154,7 @@ export default async function Home({
<NewsSection locale={locale} />
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
<OnboardingTour />
</>
);

View File

@@ -5,6 +5,8 @@ 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';
@@ -14,6 +16,30 @@ 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);
@@ -60,6 +86,7 @@ export default async function SpecialPage({ params }: PageProps) {
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
const activeSpecials = specials.filter(s => {
if (s.hidden) return false;
const sStarted = !s.launchDate || s.launchDate <= now;
const sEnded = s.endDate && s.endDate < now;
return sStarted && !sEnded;

View File

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

View File

@@ -1,14 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Hördle Admin Dashboard",
description: "Admin dashboard for managing songs and daily puzzles",
};
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +1,59 @@
'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(() => {
fetchSpecial();
}, [specialId]);
const fetchSpecial = async () => {
const fetchSpecial = async (showLoading = true) => {
try {
if (showLoading) {
setLoading(true);
}
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);
if (showLoading) {
setLoading(false);
}
}
};
const handleStartTimeChange = (newStartTime: number) => {
setPendingStartTime(newStartTime);
setHasUnsavedChanges(true);
};
useEffect(() => {
fetchSpecial(true);
}, [specialId]);
const handleSave = async () => {
if (!special || !selectedSongId || pendingStartTime === null) return;
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 }),
});
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}`);
} else {
// Reload special data to update the start time in the song list
await fetchSpecial(false);
}
};
@@ -117,116 +74,16 @@ export default function SpecialEditorPage() {
);
}
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
const unlockSteps = JSON.parse(special.unlockSteps);
const totalDuration = unlockSteps[unlockSteps.length - 1];
return (
<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."
/>
);
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
// Delete all daily puzzles (activations)
const result = await prisma.dailyPuzzle.deleteMany({});
return NextResponse.json({
success: true,
message: `Successfully deleted ${result.count} daily puzzles (activations)`,
count: result.count,
});
} catch (error) {
console.error('Error resetting activations:', error);
return NextResponse.json(
{ error: 'Failed to reset activations' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
// Reset all song ratings to 0
const result = await prisma.song.updateMany({
data: {
averageRating: 0,
ratingCount: 0,
},
});
return NextResponse.json({
success: true,
message: `Successfully reset ratings for ${result.count} songs`,
count: result.count,
});
} catch (error) {
console.error('Error resetting ratings:', error);
return NextResponse.json(
{ error: 'Failed to reset ratings' },
{ status: 500 }
);
}
}

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

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

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

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

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

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

View File

@@ -0,0 +1,65 @@
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 });
}
// Filtere Songs ohne vollständige Song-Daten (song, song.filename)
// Dies verhindert Fehler im Frontend, wenn Songs gelöscht wurden oder Daten fehlen
const filteredSongs = special.songs.filter(ss => ss.song && ss.song.filename);
return NextResponse.json({
...special,
songs: filteredSongs,
});
}

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

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

78
app/api/og-image/route.ts Normal file
View 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',
},
});
}

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

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

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

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

View File

@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku';
export async function POST(request: NextRequest) {
try {
const { message } = await request.json();
if (!message || typeof message !== 'string') {
return NextResponse.json(
{ error: 'Message is required and must be a string' },
{ status: 400 }
);
}
if (!OPENROUTER_API_KEY) {
console.error('OPENROUTER_API_KEY is not configured');
// Fallback: return original message if API key is missing
return NextResponse.json({ rewrittenMessage: message });
}
const prompt = `You are a content moderation assistant. Analyze the following message and determine if it is truly inappropriate, unfriendly, sexist, or offensive.
Rules:
- ONLY rewrite the message if it is genuinely unfriendly, sexist, inappropriate, or offensive
- If the message is polite, constructive, or even just neutral/critical feedback, return it UNCHANGED
- If the message needs rewriting, rewrite it to express the COMPLETE OPPOSITE meaning - make it positive, respectful, and appreciative
- Maintain the original language (German or English)
- Return ONLY the message text (either unchanged original or rewritten version), nothing else
Message: "${message}"`;
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://hoerdle.elpatron.me',
'X-Title': 'Hördle Message Rewriter'
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'user',
content: prompt
}
],
temperature: 0.7,
max_tokens: 500
})
});
if (!response.ok) {
console.error('OpenRouter API error:', await response.text());
// Fallback: return original message
return NextResponse.json({ rewrittenMessage: message });
}
const data = await response.json();
let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message;
// Remove any explanatory comments in parentheses that the AI might add
// e.g., "(This message is a friendly, positive comment expressing appreciation. No rewriting is necessary.)"
rewrittenMessage = rewrittenMessage.replace(/\s*\([^)]*\)\s*/g, '').trim();
// Remove surrounding quotes if present (AI sometimes adds quotes)
// Handle both single and double quotes, and multiple layers of quotes
rewrittenMessage = rewrittenMessage.replace(/^["']+|["']+$/g, '').trim();
// Normalize both messages for comparison (remove extra whitespace, normalize quotes, case-insensitive)
const normalizeForComparison = (text: string): string => {
return text
.trim()
.replace(/["']/g, '') // Remove all quotes for comparison
.replace(/\s+/g, ' ') // Normalize whitespace
.toLowerCase()
.replace(/[.,!?;:]\s*$/, ''); // Remove trailing punctuation for comparison
};
const originalTrimmed = message.trim();
const rewrittenTrimmed = rewrittenMessage.trim();
const originalNormalized = normalizeForComparison(originalTrimmed);
const rewrittenNormalized = normalizeForComparison(rewrittenTrimmed);
// Check if message was actually changed (content-wise, not just formatting)
// Only consider it changed if the normalized content is different
const wasChanged = originalNormalized !== rewrittenNormalized;
if (wasChanged) {
rewrittenMessage = rewrittenTrimmed + " (autocorrected by Polite-Bot)";
} else {
// Return original message if not changed (without suffix)
rewrittenMessage = originalTrimmed;
}
return NextResponse.json({ rewrittenMessage });
} catch (error) {
console.error('Error rewriting message:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

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

View File

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

View File

@@ -43,18 +43,20 @@ export async function PUT(
try {
const { id } = await params;
const specialId = parseInt(id);
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator, hidden } = await request.json();
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (maxAttempts !== undefined) updateData.maxAttempts = maxAttempts;
if (unlockSteps !== undefined) updateData.unlockSteps = typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps);
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
if (curator !== undefined) updateData.curator = curator || null;
if (hidden !== undefined) updateData.hidden = Boolean(hidden);
const special = await prisma.special.update({
where: { id: specialId },
data: {
name,
maxAttempts,
unlockSteps: typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps),
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
}
data: updateData
});
return NextResponse.json(special);

View File

@@ -35,11 +35,26 @@ export async function POST(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator, hidden = false } = await request.json();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
// Validate unlockSteps JSON
if (unlockSteps) {
try {
const parsed = JSON.parse(unlockSteps);
if (!Array.isArray(parsed)) {
return NextResponse.json({ error: 'Unlock steps must be a JSON array' }, { status: 400 });
}
if (parsed.some((item: any) => typeof item !== 'number' || item < 1)) {
return NextResponse.json({ error: 'All unlock step values must be positive numbers' }, { status: 400 });
}
} catch (e) {
return NextResponse.json({ error: 'Invalid JSON format for unlock steps. Please use an array of numbers, e.g. [2,4,7,11,16,30,60]' }, { status: 400 });
}
}
// Ensure name is stored as JSON
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
@@ -53,6 +68,7 @@ export async function POST(request: Request) {
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
hidden: Boolean(hidden),
},
});
return NextResponse.json(special);
@@ -76,11 +92,26 @@ export async function PUT(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator, hidden } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
// Validate unlockSteps JSON if provided
if (unlockSteps !== undefined) {
try {
const parsed = JSON.parse(unlockSteps);
if (!Array.isArray(parsed)) {
return NextResponse.json({ error: 'Unlock steps must be a JSON array' }, { status: 400 });
}
if (parsed.some((item: any) => typeof item !== 'number' || item < 1)) {
return NextResponse.json({ error: 'All unlock step values must be positive numbers' }, { status: 400 });
}
} catch (e) {
return NextResponse.json({ error: 'Invalid JSON format for unlock steps. Please use an array of numbers, e.g. [2,4,7,11,16,30,60]' }, { status: 400 });
}
}
const updateData: any = {};
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
@@ -89,6 +120,7 @@ export async function PUT(request: Request) {
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
if (curator !== undefined) updateData.curator = curator || null;
if (hidden !== undefined) updateData.hidden = Boolean(hidden);
const updated = await prisma.special.update({
where: { id: Number(id) },

File diff suppressed because it is too large Load Diff

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

View 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
View 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 />;
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Link } from '@/lib/navigation';
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
import { getLocalizedValue } from '@/lib/i18n';
interface CuratorSpecial {
id: number;
name: string | { de?: string; en?: string };
songCount: number;
}
export default function CuratorSpecialsClient() {
const router = useRouter();
const pathname = usePathname();
const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined;
const intlLocale = useLocale() as 'de' | 'en';
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
const t = useTranslations('Curator');
const [specials, setSpecials] = useState<CuratorSpecial[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSpecials = async () => {
try {
setLoading(true);
const res = await fetch('/api/curator/specials', {
headers: getCuratorAuthHeaders(),
});
if (!res.ok) {
if (res.status === 403) {
setError(t('specialForbidden'));
} else {
setError('Failed to load specials');
}
return;
}
const data = await res.json();
setSpecials(data);
} catch (e) {
setError('Failed to load specials');
} finally {
setLoading(false);
}
};
fetchSpecials();
}, [t]);
if (loading) {
return (
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
<p>{t('loading')}</p>
</div>
);
}
if (error) {
return (
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
<p style={{ color: 'red' }}>{error}</p>
<Link
href="/curator"
style={{
display: 'inline-block',
marginTop: '1rem',
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
}}
>
{t('backToDashboard') || 'Back to Dashboard'}
</Link>
</div>
);
}
return (
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
<header style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>
{t('curateSpecialsTitle') || 'Curate Specials'}
</h1>
<Link
href="/curator"
style={{
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
}}
>
{t('backToDashboard') || 'Back to Dashboard'}
</Link>
</div>
</header>
{specials.length === 0 ? (
<div style={{ padding: '2rem', textAlign: 'center', color: '#666' }}>
<p>{t('noSpecialsAssigned') || 'No specials assigned to you.'}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{specials.map((special) => (
<Link
key={special.id}
href={`/curator/specials/${special.id}`}
style={{
display: 'block',
padding: '1.5rem',
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
textDecoration: 'none',
color: 'inherit',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f3f4f6';
e.currentTarget.style.borderColor = '#d1d5db';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f9fafb';
e.currentTarget.style.borderColor = '#e5e7eb';
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.5rem', color: '#111827' }}>
{getLocalizedValue(special.name, locale)}
</h2>
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
{special.songCount} {special.songCount === 1 ? 'song' : 'songs'}
</p>
</div>
<div style={{ fontSize: '1.5rem', color: '#10b981' }}></div>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
'use client';
export const dynamic = 'force-dynamic';
import { useEffect, useState } from 'react';
import { useParams, useRouter, usePathname } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
import HelpTooltip from '@/components/HelpTooltip';
export default function CuratorSpecialEditorPage() {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined;
const intlLocale = useLocale() as 'de' | 'en';
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
const t = useTranslations('Curator');
const tHelp = useTranslations('CuratorHelp');
const specialId = params?.id as string;
const [special, setSpecial] = useState<CurateSpecial | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchSpecial = async (showLoading = true) => {
try {
if (showLoading) {
setLoading(true);
}
const res = await fetch(`/api/curator/specials/${specialId}`, {
headers: getCuratorAuthHeaders(),
});
if (res.status === 403) {
setError(t('specialForbidden'));
return;
}
if (!res.ok) {
setError('Failed to load special');
return;
}
const data = await res.json();
setSpecial(data);
} catch (e) {
setError('Failed to load special');
} finally {
if (showLoading) {
setLoading(false);
}
}
};
useEffect(() => {
if (specialId) {
fetchSpecial(true);
}
}, [specialId, t]);
const handleSaveStartTime = async (songId: number, startTime: number) => {
const res = await fetch(`/api/curator/specials/${specialId}/songs`, {
method: 'PUT',
headers: {
...getCuratorAuthHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ songId, startTime }),
});
if (res.status === 403) {
setError(t('specialForbidden'));
} else if (!res.ok) {
setError('Failed to save changes');
} else {
// Reload special data to update the start time in the song list
await fetchSpecial(false);
}
};
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('loadingData')}</p>
</div>
);
}
if (error) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{error}</p>
<button
onClick={() => router.push(`/${locale}/curator`)}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
border: 'none',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
{t('backToDashboard')}
</button>
</div>
);
}
if (!special) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('specialNotFound')}</p>
<button
onClick={() => router.push(`/${locale}/curator`)}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
border: 'none',
background: '#e5e7eb',
cursor: 'pointer',
}}
>
{t('backToDashboard')}
</button>
</div>
);
}
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold' }}>
{t('curateSpecialHeaderPrefix')}
</h1>
<HelpTooltip
shortText={tHelp('tooltipCurateSpecialEditorShort')}
longText={tHelp('tooltipCurateSpecialEditorLong')}
position="bottom"
/>
</div>
<button
type="button"
onClick={() => router.push(`/${locale}/curator/specials`)}
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
borderRadius: '0.5rem',
border: 'none',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
{t('backToCuratorSpecials')}
</button>
</div>
<CurateSpecialEditor
special={special}
locale={locale}
onBack={() => router.push(`/${locale}/curator/specials`)}
onSaveStartTime={handleSaveStartTime}
backLabel={t('backToCuratorSpecials')}
headerPrefix={t('curateSpecialHeaderPrefix')}
noSongsHint={t('curateSpecialNoSongs')}
noSongsSubHint={t('curateSpecialNoSongsSub')}
instructionsText={t('curateSpecialInstructions')}
savingLabel={t('saving')}
saveChangesLabel={t('saveChanges')}
savedLabel={t('saved')}
/>
</div>
);
}

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

View File

@@ -1,141 +0,0 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

20
app/robots.ts Normal file
View 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
View 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();
}
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useState } from 'react';
import WaveformEditor from '@/components/WaveformEditor';
export type LocalizedString = string | { de: string; en: string };
export interface CurateSpecialSong {
id: number;
songId: number;
startTime: number;
order: number | null;
song: {
id: number;
title: string;
artist: string;
filename: string;
};
}
export interface CurateSpecial {
id: number;
name: LocalizedString;
subtitle?: LocalizedString | null;
maxAttempts: number;
unlockSteps: string;
songs: CurateSpecialSong[];
}
export interface CurateSpecialEditorProps {
special: CurateSpecial;
locale: 'de' | 'en';
onBack: () => void;
onSaveStartTime: (songId: number, startTime: number) => Promise<void>;
backLabel?: string;
headerPrefix?: string;
noSongsHint?: string;
noSongsSubHint?: string;
instructionsText?: string;
savingLabel?: string;
saveChangesLabel?: string;
savedLabel?: string;
}
const resolveLocalized = (value: LocalizedString | null | undefined, locale: 'de' | 'en'): string | undefined => {
if (!value) return undefined;
if (typeof value === 'string') return value;
return value[locale] ?? value.en ?? value.de;
};
export default function CurateSpecialEditor({
special,
locale,
onBack,
onSaveStartTime,
backLabel = '← Back',
headerPrefix = 'Edit Special:',
noSongsHint = 'No songs assigned to this special yet.',
noSongsSubHint = 'Go back to the dashboard to add songs to this special.',
instructionsText = 'Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.',
savingLabel = '💾 Saving...',
saveChangesLabel = '💾 Save Changes',
savedLabel = '✓ Saved',
}: CurateSpecialEditorProps) {
// Filtere Songs ohne vollständige Song-Daten (song, song.filename)
const validSongs = special.songs.filter(ss => ss.song && ss.song.filename);
const [selectedSongId, setSelectedSongId] = useState<number | null>(
validSongs.length > 0 ? validSongs[0].songId : null
);
const [pendingStartTime, setPendingStartTime] = useState<number | null>(
validSongs.length > 0 ? validSongs[0].startTime : null
);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [saving, setSaving] = useState(false);
const specialName = resolveLocalized(special.name, locale) ?? `Special #${special.id}`;
const specialSubtitle = resolveLocalized(special.subtitle ?? null, locale);
const unlockSteps = JSON.parse(special.unlockSteps);
const totalDuration = unlockSteps[unlockSteps.length - 1];
const selectedSpecialSong = validSongs.find(ss => ss.songId === selectedSongId) ?? null;
const handleStartTimeChange = (newStartTime: number) => {
setPendingStartTime(newStartTime);
setHasUnsavedChanges(true);
};
const handleSave = async () => {
if (!selectedSongId || pendingStartTime === null) return;
setSaving(true);
try {
await onSaveStartTime(selectedSongId, pendingStartTime);
setHasUnsavedChanges(false);
} finally {
setSaving(false);
}
};
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ marginBottom: '2rem' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{headerPrefix} {specialName}
</h1>
{specialSubtitle && (
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
{specialSubtitle}
</p>
)}
<p style={{ color: '#666', marginTop: '0.5rem' }}>
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
</p>
</div>
{validSongs.length === 0 ? (
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
<p>{noSongsHint}</p>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
{noSongsSubHint}
</p>
</div>
) : (
<div>
<div style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Select Song to Curate
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
{validSongs.map(ss => (
<div
key={ss.songId}
onClick={() => {
setSelectedSongId(ss.songId);
setPendingStartTime(ss.startTime);
setHasUnsavedChanges(false);
}}
style={{
padding: '1rem',
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
color: selectedSongId === ss.songId ? 'white' : 'black',
borderRadius: '0.5rem',
cursor: 'pointer',
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
}}
>
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
Start: {ss.startTime}s
</div>
</div>
))}
</div>
</div>
{selectedSpecialSong && selectedSpecialSong.song && selectedSpecialSong.song.filename ? (
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Curate: {selectedSpecialSong.song.title}
</h2>
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
{instructionsText}
</p>
<button
onClick={handleSave}
disabled={!hasUnsavedChanges || saving}
style={{
padding: '0.5rem 1.5rem',
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
color: hasUnsavedChanges ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '0.5rem',
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
fontWeight: 'bold',
fontSize: '0.875rem',
whiteSpace: 'nowrap'
}}
>
{saving ? savingLabel : hasUnsavedChanges ? saveChangesLabel : savedLabel}
</button>
</div>
<WaveformEditor
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
duration={totalDuration}
unlockSteps={unlockSteps}
onStartTimeChange={handleStartTimeChange}
/>
</div>
</div>
) : selectedSpecialSong ? (
<div style={{ padding: '2rem', background: '#fee2e2', borderRadius: '0.5rem', textAlign: 'center' }}>
<p style={{ color: '#991b1b', fontWeight: 'bold' }}>
Fehler: Song-Daten unvollständig. Bitte wählen Sie einen anderen Song.
</p>
</div>
) : null}
</div>
)}
</div>
);
}

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

View File

@@ -6,8 +6,14 @@ 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 {
@@ -32,16 +38,19 @@ 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) {
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);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
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);
@@ -49,7 +58,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
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 = () => {
@@ -70,17 +88,48 @@ 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]);
@@ -93,6 +142,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} 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]);
@@ -108,6 +166,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const handleGuess = (song: any) => {
if (isProcessingGuess) return;
// Prevent guessing if already solved or failed
if (gameState?.isSolved || gameState?.isFailed) {
return;
}
setIsProcessingGuess(true);
setLastAction('GUESS');
@@ -159,6 +221,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
};
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();
@@ -187,6 +252,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
};
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
@@ -249,6 +317,88 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
};
const handleCommentSubmit = async () => {
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle || !commentAIConsent) {
return;
}
setCommentSending(true);
setCommentError(null);
setRewrittenMessage(null);
try {
const playerIdentifier = getOrCreatePlayerId();
if (!playerIdentifier) {
throw new Error('Could not get player identifier');
}
// 1. Rewrite message using AI
const rewriteResponse = await fetch('/api/rewrite-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: commentText.trim() })
});
let finalMessage = commentText.trim();
if (rewriteResponse.ok) {
const rewriteData = await rewriteResponse.json();
if (rewriteData.rewrittenMessage) {
finalMessage = rewriteData.rewrittenMessage;
// Only show rewritten message if it was actually changed
// The API adds "(autocorrected by Polite-Bot)" suffix only if message was changed
const wasChanged = finalMessage.includes('(autocorrected by Polite-Bot)');
if (wasChanged) {
// Remove the suffix for display
const displayMessage = finalMessage.replace(/\s*\(autocorrected by Polite-Bot\)\s*/g, '').trim();
setRewrittenMessage(displayMessage);
} else {
// Ensure rewrittenMessage is not set if message wasn't changed
setRewrittenMessage(null);
}
}
}
// 2. Send comment
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
const genreId = isSpecial ? null : null; // API will determine from puzzle
const response = await fetch('/api/curator-comment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
puzzleId: dailyPuzzle.id,
genreId: genreId,
message: finalMessage,
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
playerIdentifier: playerIdentifier
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send comment');
}
setCommentSent(true);
setCommentText('');
// Store in localStorage that comment was sent
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
if (!commentedPuzzles.includes(dailyPuzzle.id)) {
commentedPuzzles.push(dailyPuzzle.id);
localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles));
}
} catch (error) {
console.error('Error sending comment:', error);
setCommentError(error instanceof Error ? error.message : 'Failed to send comment');
} finally {
setCommentSending(false);
}
};
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
const handleShare = async () => {
@@ -274,9 +424,25 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
let shareUrl = `https://${config.domain}`;
// Add locale prefix if not default (de)
if (locale !== 'de') {
// 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) {
@@ -335,6 +501,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
};
// 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">
@@ -347,7 +520,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div id="tour-status" className="status-bar">
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
<span>{unlockedSeconds}s {t('unlocked')}</span>
</div>
@@ -456,14 +629,123 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</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',
boxSizing: 'border-box' // Ensure padding and border are included in width
}}
disabled={commentSending}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
{commentText.length}/300
</span>
{commentError && (
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
{commentError}
</span>
)}
</div>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--foreground)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={commentAIConsent}
onChange={(e) => setCommentAIConsent(e.target.checked)}
disabled={commentSending || commentSent}
style={{ marginTop: '0.2rem', cursor: (commentSending || commentSent) ? 'not-allowed' : 'pointer' }}
/>
<span>{t('commentAIConsent')}</span>
</label>
</div>
<button
onClick={handleCommentSubmit}
disabled={!commentText.trim() || commentSending || commentSent || !commentAIConsent}
className="btn-primary"
style={{
width: '100%',
opacity: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 0.5 : 1,
cursor: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 'not-allowed' : 'pointer'
}}
>
{commentSending ? t('sending') : t('sendComment')}
</button>
</>
)}
</div>
)}
{commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
{rewrittenMessage ? (
<>
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: '0.5rem' }}>
{t('commentSent')}
</p>
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
</div>
</>
) : (
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: 0 }}>
{t('commentThankYou')}
</p>
)}
</div>
)}
{statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main>
@@ -475,6 +757,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
onSkip={handleYearSkip}
/>
)}
{showExtraPuzzlesPopover && extraPuzzle && (
<ExtraPuzzlesPopover
puzzle={extraPuzzle}
onClose={() => setShowExtraPuzzlesPopover(false)}
/>
)}
</div>
);
}
@@ -674,7 +963,11 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
}
return (
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<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) => {

View File

@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
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(() => {

175
components/HelpTooltip.tsx Normal file
View 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>
);
}

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

View File

@@ -12,10 +12,14 @@ interface WaveformEditorProps {
export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const timelineRef = useRef<HTMLCanvasElement>(null);
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
const [audioDuration, setAudioDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [playingSegment, setPlayingSegment] = useState<number | null>(null);
const [isPlayingFullTitle, setIsPlayingFullTitle] = useState(false);
const [pausedPosition, setPausedPosition] = useState<number | null>(null); // Position when paused
const [pausedType, setPausedType] = useState<'selection' | 'title' | null>(null); // Type of playback that was paused
const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in
const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning
const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds
@@ -55,6 +59,80 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
};
}, [audioUrl]);
// Draw timeline
useEffect(() => {
if (!audioDuration || !timelineRef.current) return;
const timeline = timelineRef.current;
const ctx = timeline.getContext('2d');
if (!ctx) return;
const width = timeline.width;
const height = timeline.height;
// Calculate visible range based on zoom and offset (same as waveform)
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration);
// Clear timeline
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Draw border
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, width, height);
// Calculate appropriate time interval based on visible duration
let timeInterval = 1; // Start with 1 second
if (visibleDuration > 60) timeInterval = 10;
else if (visibleDuration > 30) timeInterval = 5;
else if (visibleDuration > 10) timeInterval = 2;
else if (visibleDuration > 5) timeInterval = 1;
else if (visibleDuration > 1) timeInterval = 0.5;
else timeInterval = 0.1;
// Draw time markers
ctx.strokeStyle = '#9ca3af';
ctx.lineWidth = 1;
ctx.fillStyle = '#374151';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const startTimeMarker = Math.floor(visibleStart / timeInterval) * timeInterval;
for (let time = startTimeMarker; time <= visibleEnd; time += timeInterval) {
const timePx = ((time - visibleStart) / visibleDuration) * width;
if (timePx >= 0 && timePx <= width) {
// Draw tick mark
ctx.beginPath();
ctx.moveTo(timePx, 0);
ctx.lineTo(timePx, height);
ctx.stroke();
// Draw time label
const timeLabel = time.toFixed(timeInterval < 1 ? 1 : 0);
ctx.fillText(`${timeLabel}s`, timePx, 2);
}
}
// Draw current playback position if playing
if (playbackPosition !== null) {
const playbackPx = ((playbackPosition - visibleStart) / visibleDuration) * width;
if (playbackPx >= 0 && playbackPx <= width) {
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playbackPx, 0);
ctx.lineTo(playbackPx, height);
ctx.stroke();
}
}
}, [audioDuration, zoom, viewOffset, playbackPosition]);
useEffect(() => {
if (!audioBuffer || !canvasRef.current) return;
@@ -133,6 +211,24 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
cumulativeTime = step;
});
// Draw end marker for the last segment (at startTime + duration)
const endTime = startTime + duration;
const endPx = ((endTime - visibleStart) / visibleDuration) * width;
if (endPx >= 0 && endPx <= width) {
ctx.beginPath();
ctx.moveTo(endPx, 0);
ctx.lineTo(endPx, height);
ctx.stroke();
// Draw "End" label
ctx.setLineDash([]);
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 12px sans-serif';
ctx.fillText('End', endPx + 3, 15);
ctx.setLineDash([5, 5]);
}
ctx.setLineDash([]);
// Draw hover preview (semi-transparent)
@@ -215,11 +311,21 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
setHoverPreviewTime(null);
};
const stopPlayback = () => {
const stopPlayback = (savePosition = false) => {
if (savePosition && playbackPosition !== null) {
// Save current position for resume
setPausedPosition(playbackPosition);
// Keep playbackPosition visible (don't set to null) so cursor stays visible
} else {
// Clear paused position if stopping completely
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(null);
}
sourceRef.current?.stop();
setIsPlaying(false);
setPlayingSegment(null);
setPlaybackPosition(null);
setIsPlayingFullTitle(false);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
@@ -287,30 +393,119 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
const handlePlayFull = () => {
if (!audioBuffer || !audioContextRef.current) return;
if (isPlaying) {
stopPlayback();
} else {
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = startTime;
source.start(0, startTime, duration);
sourceRef.current = source;
setIsPlaying(true);
setPlaybackPosition(startTime);
source.onended = () => {
setIsPlaying(false);
setPlaybackPosition(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
// If full selection playback is already playing, pause it
if (isPlaying && playingSegment === null && !isPlayingFullTitle) {
stopPlayback(true); // Save position
setPausedType('selection');
return;
}
// Stop any current playback (segment, full selection, or full title)
stopPlayback();
// Determine start position (resume from pause or start from beginning)
const resumePosition = pausedType === 'selection' && pausedPosition !== null
? pausedPosition
: startTime;
const remainingDuration = resumePosition >= startTime + duration
? 0
: (startTime + duration) - resumePosition;
if (remainingDuration <= 0) {
// Already finished, reset
setPausedPosition(null);
setPausedType(null);
return;
}
// Start full selection playback
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = resumePosition;
source.start(0, resumePosition, remainingDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(resumePosition);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
setPausedPosition(null);
setPausedType(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
};
const handlePlayFullTitle = () => {
if (!audioBuffer || !audioContextRef.current) return;
// If full title playback is already playing, pause it
if (isPlaying && isPlayingFullTitle) {
stopPlayback(true); // Save position
setPausedType('title');
return;
}
// Stop any current playback (segment, full selection, or full title)
stopPlayback();
// Determine start position (resume from pause or start from beginning)
const resumePosition = pausedType === 'title' && pausedPosition !== null
? pausedPosition
: 0;
const remainingDuration = resumePosition >= audioDuration
? 0
: audioDuration - resumePosition;
if (remainingDuration <= 0) {
// Already finished, reset
setPausedPosition(null);
setPausedType(null);
return;
}
// Start full title playback (from resumePosition to audioDuration)
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = resumePosition;
source.start(0, resumePosition, remainingDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(null);
setIsPlayingFullTitle(true);
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(resumePosition);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
setPausedPosition(null);
setPausedType(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
};
const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10));
@@ -371,21 +566,38 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
)}
</div>
<canvas
ref={canvasRef}
width={800}
height={150}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
style={{
width: '100%',
height: 'auto',
cursor: 'pointer',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem'
}}
/>
<div style={{ position: 'relative' }}>
<canvas
ref={canvasRef}
width={800}
height={150}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
style={{
width: '100%',
height: 'auto',
cursor: 'pointer',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem 0.5rem 0 0',
display: 'block'
}}
/>
<canvas
ref={timelineRef}
width={800}
height={30}
style={{
width: '100%',
height: '30px',
border: '1px solid #e5e7eb',
borderTop: 'none',
borderRadius: '0 0 0.5rem 0.5rem',
display: 'block',
background: '#ffffff'
}}
/>
</div>
{/* Playback Controls */}
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -401,7 +613,29 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
fontWeight: 'bold'
}}
>
{isPlaying && playingSegment === null ? '⏸ Pause' : '▶ Play Full Selection'}
{isPlaying && playingSegment === null && !isPlayingFullTitle
? '⏸ Pause'
: (pausedType === 'selection' && pausedPosition !== null
? '▶ Resume'
: '▶ Play Full Selection')}
</button>
<button
onClick={handlePlayFullTitle}
style={{
padding: '0.5rem 1rem',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
{isPlaying && isPlayingFullTitle
? '⏸ Pause'
: (pausedType === 'title' && pausedPosition !== null
? '▶ Resume'
: '▶ Play Full Title')}
</button>
<div style={{ fontSize: '0.875rem', color: '#666' }}>

View File

@@ -8,8 +8,6 @@ services:
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
NEXT_PUBLIC_TWITTER_HANDLE: ${NEXT_PUBLIC_TWITTER_HANDLE}
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: ${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}

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

View File

@@ -8,14 +8,14 @@ Die i18n-Implementierung basiert auf [next-intl](https://next-intl-docs.vercel.a
## Unterstützte Sprachen
- **Deutsch (de)** - Standardsprache
- **Englisch (en)**
- **Englisch (en)** - Standardsprache
- **Deutsch (de)**
## URL-Struktur
Alle Routen sind lokalisiert:
- `http://localhost:3000/` → Redirect zu `/de` (Standard)
- `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)
@@ -103,8 +103,8 @@ const genreNameEn = getLocalizedValue(genre.name, 'en'); // "Rock"
**Fallback-Verhalten:**
1. Versucht die angeforderte Locale (`de` oder `en`)
2. Fallback zu `de` falls nicht vorhanden
3. Fallback zu `en` falls nicht vorhanden
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
@@ -195,7 +195,7 @@ Bestehende Daten werden automatisch migriert:
Der Proxy (`proxy.ts`) leitet Anfragen automatisch um:
- `/``/de` (Standard)
- `/``/en` (Standard)
- Ungültige Locales → 404
- Validiert Locale-Parameter
@@ -223,7 +223,7 @@ GET /api/specials?locale=en
GET /api/news?locale=de
```
Falls kein `locale` angegeben wird, wird `de` als Standard verwendet.
Falls kein `locale` angegeben wird, wird `en` als Standard verwendet.
## Best Practices

167
docs/PLAUSIBLE_SETUP.md Normal file
View 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
View 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
View 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)

View File

@@ -12,15 +12,15 @@ The application is configured via environment variables. You can set these in a
|----------|-------------|---------|
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.de` |
### Analytics (Plausible)
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
| `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

View File

@@ -9,7 +9,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
console.log('[i18n/request] incoming requestLocale:', locale);
if (!locale || !locales.includes(locale as (typeof locales)[number])) {
locale = 'de';
locale = 'en';
console.log('[i18n/request] falling back to default locale:', locale);
}

View File

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

View File

@@ -1,18 +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.elpatron.me',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
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 || 'Vibe coded with ☕ and 🍺 by',
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
View 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,
};
}

View File

@@ -29,9 +29,7 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
const allSongs = await prisma.song.findMany({
where: whereClause,
include: {
puzzles: {
where: { genreId: genreId }
},
puzzles: true, // Load ALL puzzles, not just for this genre (to use total activations)
},
});
@@ -40,26 +38,24 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
return null;
}
// Calculate weights
const weightedSongs = allSongs.map(song => ({
// Find songs with the minimum number of activations (all puzzles, not just for this genre)
// Only select from songs with the fewest activations to ensure fair distribution
const songsWithActivations = allSongs.map(song => ({
song,
weight: 1.0 / (song.puzzles.length + 1),
activations: song.puzzles.length,
}));
// Calculate total weight
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
// Find minimum activations
const minActivations = Math.min(...songsWithActivations.map(item => item.activations));
// Pick a random song based on weights
let random = Math.random() * totalWeight;
let selectedSong = weightedSongs[0].song;
// Filter to only songs with minimum activations
const songsWithMinActivations = songsWithActivations
.filter(item => item.activations === minActivations)
.map(item => item.song);
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
selectedSong = item.song;
break;
}
}
// Randomly select from songs with minimum activations
const randomIndex = Math.floor(Math.random() * songsWithMinActivations.length);
const selectedSong = songsWithMinActivations[randomIndex];
// Create the daily puzzle
try {
@@ -139,7 +135,7 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
song: {
include: {
puzzles: {
where: { specialId: special.id }
where: { specialId: special.id } // For specials, only count puzzles within this special
}
}
}
@@ -148,23 +144,25 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
if (specialSongs.length === 0) return null;
// Calculate weights
const weightedSongs = specialSongs.map(specialSong => ({
// Find songs with the minimum number of activations within this special
// Note: For specials, we only count puzzles within the special (not all puzzles),
// since specials are curated, separate lists
const songsWithActivations = specialSongs.map(specialSong => ({
specialSong,
weight: 1.0 / (specialSong.song.puzzles.length + 1),
activations: specialSong.song.puzzles.length,
}));
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
let selectedSpecialSong = weightedSongs[0].specialSong;
// Find minimum activations
const minActivations = Math.min(...songsWithActivations.map(item => item.activations));
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
selectedSpecialSong = item.specialSong;
break;
}
}
// Filter to only songs with minimum activations
const songsWithMinActivations = songsWithActivations
.filter(item => item.activations === minActivations)
.map(item => item.specialSong);
// Randomly select from songs with minimum activations
const randomIndex = Math.floor(Math.random() * songsWithMinActivations.length);
const selectedSpecialSong = songsWithMinActivations[randomIndex];
try {
dailyPuzzle = await prisma.dailyPuzzle.create({

47
lib/externalPuzzles.ts Normal file
View 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;
}

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

View File

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

View File

@@ -16,12 +16,12 @@ export function getLocalizedValue(
if (typeof value === 'object') {
if (value[locale]) return value[locale];
// Fallback to 'de'
if (value['de']) return value['de'];
// Fallback to 'en'
if (value['en']) return value['en'];
// Fallback to 'de'
if (value['de']) return value['de'];
// Fallback to first key
const keys = Object.keys(value);
if (keys.length > 0) return value[keys[0]];

64
lib/metadata.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { Metadata } from 'next';
import { config } from './config';
import { getBaseUrl } from './seo';
/**
* Generate base metadata with Open Graph, Twitter Cards, and canonical URLs
*/
export async function generateBaseMetadata(
locale: string,
path: string = '',
title?: string,
description?: string,
image?: string
): Promise<Metadata> {
const baseUrl = await getBaseUrl();
const pathSegment = path ? `/${path}` : '';
const fullUrl = `${baseUrl}/${locale}${pathSegment}`;
// Determine alternate URLs for both locales (same path for both)
const alternateLocale = locale === 'de' ? 'en' : 'de';
const alternateUrl = `${baseUrl}/${alternateLocale}${pathSegment}`;
// Default values
const metaTitle = title || config.appName;
const metaDescription = description || config.appDescription;
const ogImage = image || `${baseUrl}${config.seo.ogImage}`;
return {
title: metaTitle,
description: metaDescription,
alternates: {
canonical: fullUrl,
languages: {
[locale]: fullUrl,
[alternateLocale]: alternateUrl,
'x-default': `${baseUrl}/en${pathSegment}`,
},
},
openGraph: {
title: metaTitle,
description: metaDescription,
url: fullUrl,
siteName: config.appName,
images: [
{
url: ogImage,
width: 1200,
height: 630,
alt: metaTitle,
},
],
locale: locale,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: metaTitle,
description: metaDescription,
images: [ogImage],
...(config.seo.twitterHandle && { creator: config.seo.twitterHandle }),
},
};
}

223
lib/playerId.ts Normal file
View File

@@ -0,0 +1,223 @@
/**
* Player Identifier Management
*
* Generates and manages a unique player identifier (UUID) that is stored
* in localStorage. This identifier is used to sync game states across
* different domains (hoerdle.de and hördle.de).
*
* Device-specific isolation:
* - Each device has its own device ID stored in localStorage
* - Player ID format: {basePlayerId}:{deviceId}
* - This allows cross-domain sync on the same device while keeping devices isolated
*/
const STORAGE_KEY_PLAYER = 'hoerdle_player_id';
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
/**
* Generate a UUID v4
*/
function generateUUID(): string {
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Get or create a device ID (unique per device)
*
* The device ID is stored in localStorage and persists across sessions.
* This allows device-specific isolation of game states.
*
* @returns Device identifier (UUID v4)
*/
export function getOrCreateDeviceId(): string {
if (typeof window === 'undefined') {
return '';
}
let deviceId = localStorage.getItem(STORAGE_KEY_DEVICE);
if (!deviceId) {
deviceId = generateUUID();
localStorage.setItem(STORAGE_KEY_DEVICE, deviceId);
}
return deviceId;
}
/**
* Get the device ID without creating a new one
*
* @returns Device identifier or null if not set
*/
export function getDeviceId(): string | null {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(STORAGE_KEY_DEVICE);
}
/**
* Generate a base player ID (for cross-domain sync)
*/
function generateBasePlayerId(): string {
return generateUUID();
}
/**
* Try to find an existing base player ID from the backend
*
* Extracts the base player ID from a full player ID (format: {basePlayerId}:{deviceId})
*
* @param genreKey - Genre key to search for
* @returns Base player ID if found, null otherwise
*/
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
try {
const deviceId = getOrCreateDeviceId();
const response = await fetch('/api/player-id/suggest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ genreKey, deviceId }),
});
if (response.ok) {
const data = await response.json();
if (data.basePlayerId) {
return data.basePlayerId;
}
}
} catch (error) {
console.warn('[playerId] Failed to find existing base player ID:', error);
}
return null;
}
/**
* Combine base player ID and device ID into full player ID
* Format: {basePlayerId}:{deviceId}
*/
function combinePlayerId(basePlayerId: string, deviceId: string): string {
return `${basePlayerId}:${deviceId}`;
}
/**
* Extract base player ID from full player ID
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
*/
function extractBasePlayerId(fullPlayerId: string): string {
const colonIndex = fullPlayerId.indexOf(':');
if (colonIndex === -1) {
// Legacy format (no device ID) - return as is
return fullPlayerId;
}
return fullPlayerId.substring(0, colonIndex);
}
/**
* Get or create a player identifier
*
* Player ID format: {basePlayerId}:{deviceId}
*
* If no identifier exists in localStorage, tries to find an existing base player ID
* from the backend (for cross-domain sync). If none found, generates a new base ID.
* The device ID is always device-specific.
*
* This enables:
* - Cross-domain synchronization on the same device (same base player ID)
* - Device isolation (different device IDs)
*
* @param genreKey - Optional genre key to search for existing base player ID
* @returns Full player identifier ({basePlayerId}:{deviceId})
*/
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
if (typeof window === 'undefined') {
return '';
}
// Always get/create device ID (device-specific)
const deviceId = getOrCreateDeviceId();
// Try to get base player ID from localStorage
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!basePlayerId) {
// Try to find an existing base player ID from backend if genreKey is provided
if (genreKey) {
const existingBaseId = await findExistingBasePlayerId(genreKey);
if (existingBaseId) {
basePlayerId = existingBaseId;
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
}
// Generate new base player ID if no existing one found
if (!basePlayerId) {
basePlayerId = generateBasePlayerId();
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
}
// Combine base player ID with device ID
return combinePlayerId(basePlayerId, deviceId);
}
/**
* Get or create a player identifier (synchronous version)
*
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
*
* @returns Full player identifier ({basePlayerId}:{deviceId})
*/
export function getOrCreatePlayerId(): string {
if (typeof window === 'undefined') {
return '';
}
const deviceId = getOrCreateDeviceId();
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!basePlayerId) {
basePlayerId = generateBasePlayerId();
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
return combinePlayerId(basePlayerId, deviceId);
}
/**
* Get the current player identifier without creating a new one
*
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
*/
export function getPlayerId(): string | null {
if (typeof window === 'undefined') {
return null;
}
const deviceId = getDeviceId();
const basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!deviceId || !basePlayerId) {
return null;
}
return combinePlayerId(basePlayerId, deviceId);
}
/**
* Get base player ID (for debugging/logging)
*
* @returns Base player ID or null if not set
*/
export function getBasePlayerId(): string | null {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(STORAGE_KEY_PLAYER);
}

134
lib/playerStorage.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Player Storage API
*
* Handles loading and saving player game states from/to the backend.
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
*/
import { getOrCreatePlayerId } from './playerId';
import type { GameState, Statistics } from './gameState';
/**
* Get genre key for storage
*
* Formats the genre/special into a consistent key format:
* - Global: "global"
* - Genre: "Rock" (localized name)
* - Special: "special:00725" (special name)
*/
export function getGenreKey(
genre: string | null,
isSpecial: boolean,
specialName?: string
): string {
if (isSpecial && specialName) {
return `special:${specialName}`;
}
return genre || 'global';
}
/**
* Load player state from backend
*
* @param genreKey - Genre key (from getGenreKey)
* @returns GameState and Statistics, or null if not found
*/
export async function loadPlayerState(
genreKey: string
): Promise<{ gameState: GameState; statistics: Statistics } | null> {
try {
// Use async version to enable cross-domain player ID sync
const { getOrCreatePlayerIdAsync } = await import('./playerId');
const playerId = await getOrCreatePlayerIdAsync(genreKey);
if (!playerId) {
return null;
}
// Determine if it's a special or genre
const isSpecial = genreKey.startsWith('special:');
const params = new URLSearchParams();
if (isSpecial) {
const specialName = genreKey.replace('special:', '');
params.append('special', specialName);
} else if (genreKey !== 'global') {
params.append('genre', genreKey);
}
const response = await fetch(`/api/player-state?${params.toString()}`, {
method: 'GET',
headers: {
'X-Player-Id': playerId,
},
});
if (!response.ok) {
if (response.status === 404) {
// No state found - this is normal for new players
return null;
}
// Other errors: log and return null (will fallback to localStorage)
console.warn('[playerStorage] Failed to load player state:', response.status);
return null;
}
const data = await response.json();
if (!data || !data.gameState || !data.statistics) {
return null;
}
return {
gameState: data.gameState as GameState,
statistics: data.statistics as Statistics,
};
} catch (error) {
// Network errors or other issues: fallback to localStorage
console.warn('[playerStorage] Error loading player state:', error);
return null;
}
}
/**
* Save player state to backend
*
* @param genreKey - Genre key (from getGenreKey)
* @param gameState - Current game state
* @param statistics - Current statistics
*/
export async function savePlayerState(
genreKey: string,
gameState: GameState,
statistics: Statistics
): Promise<void> {
try {
// Use async version to ensure device ID is included
const { getOrCreatePlayerIdAsync } = await import('./playerId');
const playerId = await getOrCreatePlayerIdAsync();
if (!playerId) {
console.warn('[playerStorage] No player ID available, cannot save state');
return;
}
const response = await fetch('/api/player-state', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Player-Id': playerId,
},
body: JSON.stringify({
genreKey,
gameState,
statistics,
}),
});
if (!response.ok) {
console.warn('[playerStorage] Failed to save player state:', response.status);
// Don't throw - allow fallback to localStorage
}
} catch (error) {
// Network errors: log but don't throw (will fallback to localStorage)
console.warn('[playerStorage] Error saving player state:', error);
}
}

View File

@@ -0,0 +1,94 @@
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
const prisma = new PrismaClient();
export type PoliticalStatement = {
id: number;
locale: string;
text: string;
active: boolean;
source?: string | null;
};
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
return {
id: stmt.id,
locale: stmt.locale,
text: stmt.text,
active: stmt.active,
source: stmt.source,
};
}
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const all = await prisma.politicalStatement.findMany({
where: {
locale: safeLocale,
active: true,
},
});
if (all.length === 0) {
return null;
}
const index = Math.floor(Math.random() * all.length);
return mapFromPrisma(all[index]);
}
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const all = await prisma.politicalStatement.findMany({
where: { locale: safeLocale },
orderBy: { id: 'asc' },
});
return all.map(mapFromPrisma);
}
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const created = await prisma.politicalStatement.create({
data: {
locale: safeLocale,
text: input.text,
active: input.active ?? true,
source: input.source ?? null,
},
});
return mapFromPrisma(created);
}
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return null;
}
const updated = await prisma.politicalStatement.update({
where: { id },
data: {
text: input.text ?? existing.text,
active: input.active ?? existing.active,
source: input.source !== undefined ? input.source : existing.source,
},
});
return mapFromPrisma(updated);
}
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
if (!existing || existing.locale !== safeLocale) {
return false;
}
await prisma.politicalStatement.delete({ where: { id } });
return true;
}

43
lib/seo.ts Normal file
View File

@@ -0,0 +1,43 @@
import { headers } from 'next/headers';
import { config } from './config';
/**
* Get the current base URL from request headers
* Automatically detects hoerdle.de or hördle.de (xn--hrdle-jua.de)
*/
export async function getBaseUrl(): Promise<string> {
const headersList = await headers();
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
let domain = config.domain; // Default fallback
if (host) {
// Extract domain from host (remove port if present)
const detectedDomain = host.split(':')[0].toLowerCase();
// Map domains
if (detectedDomain === 'hoerdle.de') {
domain = 'hoerdle.de';
} else if (detectedDomain === 'hördle.de' || detectedDomain === 'xn--hrdle-jua.de') {
domain = 'hördle.de';
} else {
// Use detected domain if it's different from default
domain = detectedDomain;
}
}
// Always use HTTPS in production
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
return `${protocol}://${domain}`;
}
/**
* Get base URL synchronously (for use in non-async contexts)
* Uses environment variable or config as fallback
*/
export function getBaseUrlSync(): string {
const domain = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
return `${protocol}://${domain}`;
}

View File

@@ -1,156 +1,404 @@
{
"Common": {
"loading": "Laden...",
"error": "Ein Fehler ist aufgetreten",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"back": "Zurück"
},
"Navigation": {
"home": "Startseite",
"admin": "Admin",
"global": "Global",
"news": "Neuigkeiten"
},
"Game": {
"play": "Abspielen",
"pause": "Pause",
"skip": "Überspringen",
"submit": "Raten",
"next": "Nächstes",
"won": "Gewonnen!",
"lost": "Verloren",
"correct": "Richtig!",
"wrong": "Falsch",
"guessPlaceholder": "Lied oder Interpret eingeben...",
"attempts": "Versuche",
"share": "Teilen",
"nextPuzzle": "Nächstes Rätsel in",
"noPuzzleAvailable": "Kein Rätsel verfügbar",
"noPuzzleDescription": "Tägliches Rätsel konnte nicht generiert werden.",
"noPuzzleGenre": "Bitte stelle sicher, dass Songs in der Datenbank vorhanden sind",
"goToAdmin": "Zum Admin-Dashboard gehen",
"loadingState": "Lade Status...",
"attempt": "Versuch",
"unlocked": "freigeschaltet",
"start": "Start",
"skipWithBonus": "Überspringen (+{seconds}s)",
"solveGiveUp": "Lösen (Aufgeben)",
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
"theSongWas": "Das Lied war:",
"score": "Punkte",
"scoreBreakdown": "Punkteaufschlüsselung",
"albumCover": "Album-Cover",
"released": "Veröffentlicht",
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
"thanksForRating": "Danke für die Bewertung!",
"rateThisPuzzle": "Bewerte dieses Rätsel:",
"shared": "✓ Geteilt!",
"copied": "✓ Kopiert!",
"shareFailed": "✗ Fehlgeschlagen",
"bonusRound": "Bonus-Runde!",
"guessReleaseYear": "Errate das Veröffentlichungsjahr für",
"points": "Punkte",
"skipBonus": "Bonus überspringen",
"notQuite": "Nicht ganz!",
"youGuessed": "Du hast geraten",
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
"skipped": "Übersprungen",
"gameOverPlaceholder": "Spiel beendet",
"knowItSearch": "Weißt du es? Suche nach Interpret / Titel",
"special": "Special",
"genre": "Genre"
},
"Statistics": {
"yourStatistics": "Deine Statistiken",
"totalPuzzles": "Gesamte Rätsel",
"try": "Versuch",
"failed": "Verloren"
},
"OnboardingTour": {
"done": "Fertig",
"next": "Weiter",
"previous": "Zurück",
"genresSpecials": "Genres & Specials",
"genresSpecialsDescription": "Wähle hier ein bestimmtes Genre oder ein kuratiertes Special-Event.",
"news": "Neuigkeiten",
"newsDescription": "Bleibe auf dem Laufenden mit den neuesten Nachrichten und Ankündigungen.",
"hoerdle": "Hördle",
"hoerdleDescription": "Das ist das tägliche Rätsel. Ein neues Lied jeden Tag pro Genre.",
"attempts": "Versuche",
"attemptsDescription": "Du hast eine begrenzte Anzahl von Versuchen, um das Lied zu erraten.",
"score": "Punkte",
"scoreDescription": "Deine aktuelle Punktzahl. Versuche sie hoch zu halten!",
"player": "Player",
"playerDescription": "Höre dir den Ausschnitt an. Jedes zusätzliche Abspielen reduziert deine mögliche Punktzahl.",
"input": "Eingabe",
"inputDescription": "Gib hier deine Vermutung ein. Suche nach Interpret oder Titel.",
"controls": "Steuerung",
"controlsDescription": "Starte die Musik oder überspringe zum nächsten Ausschnitt, wenn du feststeckst."
},
"InstallPrompt": {
"installApp": "Hördle App installieren",
"installDescription": "Installiere die App für eine bessere Erfahrung und schnellen Zugriff!",
"iosInstructions": "Tippe auf",
"iosShare": "Teilen",
"iosThen": "dann \"Zum Home-Bildschirm hinzufügen\"",
"installButton": "App installieren"
},
"Home": {
"welcome": "Willkommen bei Hördle",
"subtitle": "Errate den Song anhand kurzer Ausschnitte",
"globalTooltip": "Ein zufälliger Song aus der gesamten Sammlung",
"comingSoon": "Demnächst",
"curatedBy": "Kuratiert von"
},
"Admin": {
"title": "Hördle Admin Dashboard",
"login": "Admin Login",
"password": "Passwort",
"loginButton": "Login",
"logout": "Abmelden",
"manageSpecials": "Specials verwalten",
"manageGenres": "Genres verwalten",
"manageNews": "News & Ankündigungen verwalten",
"uploadSongs": "Songs hochladen",
"todaysPuzzles": "Heutige tägliche Rätsel",
"show": "▶ Anzeigen",
"hide": "▼ Ausblenden",
"addSpecial": "Special hinzufügen",
"addGenre": "Genre hinzufügen",
"addNews": "News hinzufügen",
"edit": "Bearbeiten",
"delete": "Löschen",
"save": "Speichern",
"cancel": "Abbrechen",
"curate": "Kurieren",
"name": "Name",
"subtitle": "Untertitel",
"maxAttempts": "Max. Versuche",
"unlockSteps": "Freischalt-Schritte",
"launchDate": "Startdatum",
"endDate": "Enddatum",
"curator": "Kurator",
"active": "Aktiv",
"newGenreName": "Neuer Genre-Name",
"editSpecial": "Special bearbeiten",
"editGenre": "Genre bearbeiten",
"editNews": "News bearbeiten",
"newsTitle": "News-Titel",
"content": "Inhalt (Markdown unterstützt)",
"author": "Autor (optional)",
"featured": "Hervorgehoben",
"noSpecialLink": "Kein Special-Link",
"noNewsItems": "Noch keine News-Einträge. Erstelle einen oben!",
"noPuzzlesToday": "Keine täglichen Rätsel für heute gefunden.",
"category": "Kategorie",
"song": "Song",
"artist": "Interpret",
"actions": "Aktionen",
"deletePuzzle": "Löschen",
"wrongPassword": "Falsches Passwort"
"Common": {
"loading": "Laden...",
"error": "Ein Fehler ist aufgetreten",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"back": "Zurück"
},
"Navigation": {
"home": "Startseite",
"admin": "Admin",
"global": "Global",
"news": "Neuigkeiten"
},
"Game": {
"play": "Abspielen",
"pause": "Pause",
"skip": "Überspringen",
"submit": "Raten",
"next": "Nächstes",
"won": "Gewonnen!",
"lost": "Verloren",
"correct": "Richtig!",
"wrong": "Falsch",
"guessPlaceholder": "Lied oder Interpret eingeben...",
"attempts": "Versuche",
"share": "Teilen",
"nextPuzzle": "Nächstes Rätsel in",
"noPuzzleAvailable": "Kein Rätsel verfügbar",
"noPuzzleDescription": "Tägliches Rätsel konnte nicht generiert werden.",
"noPuzzleGenre": "Bitte stelle sicher, dass Songs in der Datenbank vorhanden sind",
"goToAdmin": "Zum Admin-Dashboard gehen",
"loadingState": "Lade Status...",
"attempt": "Versuch",
"unlocked": "freigeschaltet",
"start": "Start",
"skipWithBonus": "Überspringen (+{seconds}s)",
"solveGiveUp": "Lösen (Aufgeben)",
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
"theSongWas": "Das Lied war:",
"score": "Punkte",
"shareExplanation": "Teile dein Ergebnis mit Freund:innen so hilfst du, Hördle bekannter zu machen.",
"scoreBreakdown": "Punkteaufschlüsselung",
"albumCover": "Album-Cover",
"released": "Veröffentlicht",
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
"thanksForRating": "Danke für die Bewertung!",
"rateThisPuzzle": "Bewerte dieses Rätsel:",
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
"shared": "✓ Geteilt!",
"copied": "✓ Kopiert!",
"shareFailed": "✗ Fehlgeschlagen",
"bonusRound": "Bonus-Runde!",
"guessReleaseYear": "Errate das Veröffentlichungsjahr für",
"points": "Punkte",
"skipBonus": "Bonus überspringen",
"notQuite": "Nicht ganz!",
"sendComment": "Nachricht an Kurator senden",
"sendCommentCollapsed": "Nachricht an Kurator senden",
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres... Bitte bleibe freundlich und höflich.",
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
"commentAIConsent": "Ich bin damit einverstanden, dass diese Nachricht von einer KI verarbeitet wird, um unfreundliche Nachrichten zu filtern.",
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
"commentThankYou": "Vielen Dank für dein Feedback!",
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
"commentError": "Fehler beim Senden der Nachricht",
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
"sending": "Wird gesendet...",
"youGuessed": "Du hast geraten",
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
"skipped": "Übersprungen",
"gameOverPlaceholder": "Spiel beendet",
"knowItSearch": "Weißt du es? Suche nach Interpret / Titel",
"special": "Special",
"genre": "Genre"
},
"ExtraPuzzles": {
"title": "Noch nicht genug Rätsel?",
"message": "Hey, hast du Lust auf weitere Rätsel? Dann schau doch mal bei {name} vorbei!",
"cta": "Zu {name}",
"close": "Schließen"
},
"Statistics": {
"yourStatistics": "Deine Statistiken",
"totalPuzzles": "Gesamte Rätsel",
"try": "Versuch",
"failed": "Verloren"
},
"OnboardingTour": {
"done": "Fertig",
"next": "Weiter",
"previous": "Zurück",
"genresSpecials": "Genres & Specials",
"genresSpecialsDescription": "Wähle hier ein bestimmtes Genre oder ein kuratiertes Special-Event.",
"news": "Neuigkeiten",
"newsDescription": "Bleibe auf dem Laufenden mit den neuesten Nachrichten und Ankündigungen.",
"hoerdle": "Hördle",
"hoerdleDescription": "Das ist das tägliche Rätsel. Ein neues Lied jeden Tag pro Genre.",
"attempts": "Versuche",
"attemptsDescription": "Du hast eine begrenzte Anzahl von Versuchen, um das Lied zu erraten.",
"score": "Punkte",
"scoreDescription": "Deine aktuelle Punktzahl. Versuche sie hoch zu halten!",
"player": "Player",
"playerDescription": "Höre dir den Ausschnitt an. Jedes zusätzliche Abspielen reduziert deine mögliche Punktzahl.",
"input": "Eingabe",
"inputDescription": "Gib hier deine Vermutung ein. Suche nach Interpret oder Titel.",
"controls": "Steuerung",
"controlsDescription": "Starte die Musik oder überspringe zum nächsten Ausschnitt, wenn du feststeckst."
},
"InstallPrompt": {
"installApp": "Hördle App installieren",
"installDescription": "Installiere die App für eine bessere Erfahrung und schnellen Zugriff!",
"iosInstructions": "Tippe auf",
"iosShare": "Teilen",
"iosThen": "dann \"Zum Home-Bildschirm hinzufügen\"",
"installButton": "App installieren"
},
"Home": {
"welcome": "Willkommen bei Hördle",
"subtitle": "Errate den Song anhand kurzer Ausschnitte",
"globalTooltip": "Ein zufälliger Song aus der gesamten Sammlung",
"comingSoon": "Demnächst",
"curatedBy": "Kuratiert von"
},
"Admin": {
"title": "Hördle Admin Dashboard",
"login": "Admin Login",
"password": "Passwort",
"loginButton": "Login",
"logout": "Abmelden",
"manageSpecials": "Specials verwalten",
"manageGenres": "Genres verwalten",
"manageNews": "News & Ankündigungen verwalten",
"uploadSongs": "Songs hochladen",
"todaysPuzzles": "Heutige tägliche Rätsel",
"show": "▶ Anzeigen",
"hide": "▼ Ausblenden",
"addSpecial": "Special hinzufügen",
"addGenre": "Genre hinzufügen",
"addNews": "News hinzufügen",
"edit": "Bearbeiten",
"delete": "Löschen",
"save": "Speichern",
"cancel": "Abbrechen",
"curate": "Kuratieren",
"name": "Name",
"subtitle": "Untertitel",
"maxAttempts": "Max. Versuche",
"unlockSteps": "Freischalt-Schritte",
"unlockStepsRequired": "Freischalt-Schritte sind erforderlich",
"unlockStepsInvalidJson": "Ungültiges JSON-Format. Bitte verwende ein Array von Zahlen, z.B. [2,4,7,11,16,30,60]",
"unlockStepsMustBeArray": "Freischalt-Schritte müssen ein Array sein",
"unlockStepsMustBePositiveNumbers": "Alle Werte müssen positive Zahlen sein",
"launchDate": "Startdatum",
"endDate": "Enddatum",
"curator": "Kurator",
"active": "Aktiv",
"newGenreName": "Neuer Genre-Name",
"editSpecial": "Special bearbeiten",
"editGenre": "Genre bearbeiten",
"editNews": "News bearbeiten",
"newsTitle": "News-Titel",
"content": "Inhalt (Markdown unterstützt)",
"author": "Autor (optional)",
"featured": "Hervorgehoben",
"noSpecialLink": "Kein Special-Link",
"noNewsItems": "Noch keine News-Einträge. Erstelle einen oben!",
"noPuzzlesToday": "Keine täglichen Rätsel für heute gefunden.",
"category": "Kategorie",
"song": "Song",
"artist": "Interpret",
"actions": "Aktionen",
"deletePuzzle": "Löschen",
"wrongPassword": "Falsches Passwort",
"manageCurators": "Kuratoren verwalten",
"addCurator": "Kurator hinzufügen",
"curatorUsername": "Benutzername",
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
"assignedGenres": "Zugeordnete Genres",
"assignedSpecials": "Zugeordnete Specials",
"noCurators": "Noch keine Kuratoren angelegt."
},
"Curator": {
"loginTitle": "Kuratoren-Login",
"loginUsername": "Benutzername",
"loginPassword": "Passwort",
"loginButton": "Einloggen",
"logout": "Abmelden",
"loginFailed": "Login fehlgeschlagen.",
"loginNetworkError": "Netzwerkfehler beim Login.",
"loadCuratorError": "Fehler beim Laden der Kuratoren-Informationen.",
"loadSongsError": "Fehler beim Laden der Songs.",
"songUpdated": "Song erfolgreich aktualisiert.",
"saveError": "Fehler beim Speichern: {error}",
"saveNetworkError": "Netzwerkfehler beim Speichern.",
"noDeletePermission": "Du darfst diesen Song nicht löschen.",
"deleteConfirm": "Möchtest du \"{title}\" wirklich löschen?",
"songDeleted": "Song gelöscht.",
"deleteError": "Fehler beim Löschen: {error}",
"deleteNetworkError": "Netzwerkfehler beim Löschen.",
"uploadSectionTitle": "Titel hochladen",
"uploadSectionDescription": "Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert (inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens eines deiner Genres aus, um die Titel zuzuordnen.",
"dropzoneTitleEmpty": "MP3-Dateien hierher ziehen",
"dropzoneTitleWithFiles": "{count} Datei(en) ausgewählt",
"dropzoneSubtitle": "oder klicken, um Dateien auszuwählen",
"selectedFilesTitle": "Ausgewählte Dateien:",
"uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Genres zuordnen",
"assignSpecialsLabel": "Specials zuordnen",
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
"uploadButtonIdle": "Upload starten",
"uploadButtonUploading": "Lade hoch...",
"uploadSummary": "✅ {success}/{total} Uploads erfolgreich.",
"uploadSummaryDuplicates": "⚠️ {count} Duplikat(e) übersprungen.",
"uploadSummaryFailed": "❌ {count} fehlgeschlagen.",
"uploadResultSuccess": "✅ erfolgreich",
"uploadResultDuplicate": "⚠️ Duplikat: {error}",
"uploadResultError": "❌ Fehler: {error}",
"tracklistTitle": "Titel in deinen Genres & Specials ({count} Titel)",
"tracklistDescription": "Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist. Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.",
"searchPlaceholder": "Nach Titel oder Artist suchen...",
"filterAll": "Alle Inhalte",
"filterNoGlobal": "🚫 Ohne Global",
"filterReset": "Filter zurücksetzen",
"noSongsInScope": "Keine passenden Songs in deinen Genres/Specials gefunden.",
"columnId": "ID",
"columnPlay": "Play",
"columnTitle": "Titel",
"columnArtist": "Artist",
"columnYear": "Jahr",
"columnCover": "Cover",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Hinzugefügt",
"columnActivations": "Aktivierungen",
"columnRating": "Rating",
"columnExcludeGlobal": "Exclude Global",
"columnActions": "Aktionen",
"play": "Abspielen",
"pause": "Pause",
"excludeGlobalYes": "Ja",
"excludeGlobalNo": "Nein",
"excludeGlobalInfo": "Nur globale Kuratoren dürfen dieses Flag ändern.",
"paginationPrev": "Zurück",
"paginationNext": "Weiter",
"paginationLabel": "Seite {page} von {total}",
"loadingData": "Lade Daten...",
"loggedInAs": "Eingeloggt als {username}",
"globalCuratorSuffix": " (Globaler Kurator)",
"pageSizeLabel": "Pro Seite:",
"commentsTitle": "Kommentare",
"showComments": "Kommentare anzeigen",
"hideComments": "Kommentare ausblenden",
"loadingComments": "Kommentare werden geladen...",
"noComments": "Keine Kommentare vorhanden.",
"loadCommentsError": "Fehler beim Laden der Kommentare.",
"commentFromPuzzle": "Kommentar zu Puzzle",
"commentGenre": "Genre",
"unreadComment": "Ungelesen",
"archiveComment": "Archivieren",
"archiveCommentConfirm": "Möchtest du diesen Kommentar wirklich archivieren?",
"archiveCommentError": "Fehler beim Archivieren des Kommentars.",
"newComments": "neu",
"batchEditTitle": "Batch-Bearbeitung",
"clearSelection": "Auswahl aufheben",
"batchToggleGenres": "Genres umschalten",
"batchToggleSpecials": "Specials umschalten",
"batchChangeArtist": "Artist ändern",
"batchArtistPlaceholder": "Neuen Artist-Namen eingeben",
"batchExcludeGlobal": "Von Global ausschließen",
"batchNoChange": "Keine Änderung",
"batchExclude": "Ausschließen",
"batchInclude": "Einschließen",
"batchUpdating": "Aktualisiere...",
"batchApply": "Änderungen anwenden",
"selectAll": "Alle auswählen",
"selectSong": "Titel auswählen",
"cannotEditSong": "Dieser Titel kann nicht bearbeitet werden",
"noSongsSelected": "Keine Titel ausgewählt",
"noBatchOperations": "Keine Batch-Operationen angegeben",
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
"batchUpdateError": "Fehler: {error}",
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung",
"backToDashboard": "Zurück zum Dashboard",
"loading": "Laden...",
"curateSpecialsButton": "Specials kuratieren",
"curateSpecialsTitle": "Deine Specials kuratieren",
"curateSpecialsDescription": "Hier kannst du die Startzeiten der Songs in deinen zugewiesenen Specials für das Rätsel feinjustieren.",
"noSpecialPermissions": "Dir sind keine Specials zugeordnet.",
"noSpecialsInScope": "Keine Specials zum Kuratieren vorhanden.",
"noSpecialsAssigned": "Dir sind keine Specials zugeordnet.",
"curateSpecialSongCount": "{count, plural, one {# Song} other {# Songs}} in diesem Special",
"curateSpecialOpen": "Öffnen",
"specialForbidden": "Du darfst dieses Special nicht bearbeiten.",
"specialNotFound": "Special nicht gefunden.",
"backToCuratorSpecials": "Zurück zur Special-Übersicht",
"curateSpecialHeaderPrefix": "Special kuratieren:",
"curateSpecialNoSongs": "Diesem Special sind noch keine Songs zugeordnet.",
"curateSpecialNoSongsSub": "Gehe zurück zu deinem Dashboard, um diesem Special Songs zuzuordnen.",
"curateSpecialInstructions": "Klicke auf die Waveform, um zu wählen, wo das Rätsel starten soll. Der hervorgehobene Bereich zeigt, was die Spieler hören.",
"saving": "💾 Speichere...",
"saveChanges": "💾 Änderungen speichern",
"saved": "✓ Gespeichert"
},
"CuratorHelp": {
"title": "Kurator-Hilfe & Handbuch",
"backToDashboard": "Zurück zum Dashboard",
"helpButton": "Hilfe",
"modalTitle": "Hilfe",
"introductionTitle": "Einführung",
"introductionText": "Als Kurator bist du verantwortlich für die Verwaltung von Songs in deinen zugewiesenen Genres und Specials. Dieses Dashboard ermöglicht es dir, Musik für das Hördle-Spiel hochzuladen, zu bearbeiten und zu organisieren.",
"permissionsTitle": "Deine Berechtigungen",
"permission1": "MP3-Dateien hochladen und deinen Genres zuordnen",
"permission2": "Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind",
"permission3": "Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind",
"permission4": "Kommentare von Spielern zu deinen Rätseln einsehen und verwalten",
"note": "Hinweis",
"permissionNote": "Du kannst nur Songs bearbeiten oder löschen, die deinen Genres/Specials zugeordnet sind. Songs, die anderen Kuratoren zugeordnet sind, kannst du nicht ändern.",
"uploadTitle": "Songs hochladen",
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
"uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen",
"uploadStep2": "Ein oder mehrere Genres und falls passend Specials auswählen, um sie den hochgeladenen Songs zuzuordnen",
"uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen",
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
"uploadBestPracticesTitle": "Best Practices",
"uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben",
"uploadBestPractice2": "Passende Genres (und Specials) vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
"uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert",
"tip": "Tipp",
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",
"editingTitle": "Songs bearbeiten",
"singleEditTitle": "Einzelne Song-Bearbeitung",
"singleEditText": "Klicke auf den Bearbeiten-Button (✏️) neben einem Song, um Titel, Artist, Erscheinungsjahr, Genres, Specials oder das Exclude-Global-Flag zu ändern. Nur Songs, die du bearbeiten kannst, haben einen aktiven Bearbeiten-Button.",
"batchEditTitle": "Batch-Bearbeitung",
"batchEditText": "Wähle mehrere Songs über die Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden:",
"batchEditFeature1": "Genre Toggle: Genres zu allen ausgewählten Songs hinzufügen oder entfernen",
"batchEditFeature2": "Special Toggle: Specials zu allen ausgewählten Songs hinzufügen oder entfernen",
"batchEditFeature3": "Artist ändern: Gleichen Artist-Namen für alle ausgewählten Songs setzen",
"batchEditFeature4": "Exclude Global Flag: Exclude-Global-Flag setzen oder entfernen (nur Global-Kuratoren)",
"genreSpecialAssignmentTitle": "Genre & Special Zuordnung",
"genreSpecialAssignmentText": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Songs können mehrere Genres und Specials haben. Beim Bearbeiten kannst du Zuordnungen umschalten - wenn ein Genre/Special bereits zugeordnet ist, wird es entfernt; wenn nicht, wird es hinzugefügt.",
"commentsTitle": "Kommentare verwalten",
"commentsText": "Spieler können dir Feedback zu Rätseln in deinen Genres oder Specials senden. Kommentare erscheinen in deinem Dashboard mit einem Badge für ungelesene Nachrichten.",
"commentsActionsTitle": "Verfügbare Aktionen",
"markAsRead": "Als gelesen markieren",
"markAsReadText": "Klicke auf einen Kommentar, um ihn als gelesen zu markieren. Gelesene Kommentare werden mit einem grauen Rahmen angezeigt.",
"archive": "Archivieren",
"archiveText": "Archiviere Kommentare, die du nicht mehr benötigst. Archivierte Kommentare werden aus deiner Ansicht entfernt.",
"bestPracticesTitle": "Best Practices für Kuratoren",
"bestPractice1": "Metadaten korrekt halten: Stelle sicher, dass Song-Titel und Artist-Namen korrekt und konsistent sind",
"bestPractice2": "Passende Genres verwenden: Ordne Songs den relevantesten Genres zu, um Spielern zu helfen, Musik zu entdecken",
"bestPractice3": "Auf Kommentare reagieren: Prüfe Kommentare regelmäßig und berücksichtige Spieler-Feedback beim Kuratieren",
"bestPractice4": "Qualität wahren: Überprüfe hochgeladene Songs auf Audio-Qualität und Metadaten-Genauigkeit",
"bestPractice5": "Batch-Bearbeitung effizient nutzen: Wenn du ähnliche Änderungen an mehreren Songs vornehmen musst, nutze die Batch-Bearbeitung, um Zeit zu sparen",
"troubleshootingTitle": "Troubleshooting",
"troubleshootingQ1": "Warum kann ich einen Song nicht bearbeiten?",
"troubleshootingA1": "Du kannst nur Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Wenn ein Song keine Genres/Specials zugeordnet hat, kannst du ihn bearbeiten. Wenn er nur anderen Kuratoren zugeordnet ist, kannst du ihn nicht bearbeiten.",
"troubleshootingQ2": "Warum kann ich einen Song nicht löschen?",
"troubleshootingA2": "Du kannst nur Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind. Wenn ein Song Genres oder Specials hat, die anderen Kuratoren zugeordnet sind, kannst du ihn nicht löschen.",
"troubleshootingQ3": "Warum kann ich ein Genre/Special nicht zuordnen?",
"troubleshootingA3": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Wende dich an den Admin, wenn du Zugriff auf zusätzliche Genres oder Specials benötigst.",
"troubleshootingQ4": "Warum ist die Exclude-Global-Checkbox deaktiviert?",
"troubleshootingA4": "Nur Global-Kuratoren können das Exclude-Global-Flag ändern. Wenn du diese Berechtigung benötigst, wende dich an den Admin.",
"curateSpecialsHelpTitle": "Specials kuratieren",
"curateSpecialsHelpIntro": "Im Bereich „Curate Specials\" kannst du den exakten Audio-Ausschnitt festlegen, den Spieler in deinen Specials hören. Es werden nur Specials angezeigt, die dir zugewiesen sind.",
"curateSpecialsHelpStepsTitle": "So kuratierst du Specials",
"curateSpecialsHelpStep1": "Öffne das Kuratoren-Dashboard und klicke auf „Curate Specials\", um alle dir zugewiesenen Specials zu sehen.",
"curateSpecialsHelpStep2": "Wähle ein Special aus der Liste, um den Waveform-Editor für dieses Special zu öffnen.",
"curateSpecialsHelpStep3": "Klicke auf die Waveform, um die Startzeit zu wählen. Der hervorgehobene Bereich zeigt genau das, was Spieler hören werden.",
"curateSpecialsHelpStep4": "Nutze Zoom, Pan und Segment-Playback, um den Ausschnitt fein abzustimmen. Klicke auf „Änderungen speichern\", um die neue Startzeit zu übernehmen.",
"curateSpecialsPermissionsNote": "Du kannst nur Specials kuratieren, die dir zugewiesen sind. Wenn du versuchst, ein fremdes Special zu öffnen oder zu speichern, blockiert das System die Aktion.",
"tooltipDashboardShort": "Übersicht über dein Kuratoren-Dashboard",
"tooltipDashboardLong": "Dies ist dein Haupt-Dashboard, wo du Songs hochladen, deine Track-Liste verwalten und Kommentare von Spielern einsehen kannst. Nutze den Hilfe-Button (❓), um auf das vollständige Handbuch zuzugreifen.",
"tooltipUploadShort": "MP3-Dateien zu deinen Genres hochladen",
"tooltipUploadLong": "Ziehe MP3-Dateien per Drag & Drop oder klicke, um sie auszuwählen. Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus ID3-Tags. Wähle Genres vor dem Upload aus, um Songs automatisch zuzuordnen. Alle Kuratoren-Uploads sind standardmäßig von der globalen Playlist ausgeschlossen.",
"tooltipGenreAssignmentShort": "Genres zu hochgeladenen Songs zuordnen",
"tooltipGenreAssignmentLong": "Wähle ein oder mehrere Genres vor dem Upload aus. Die ausgewählten Genres werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Genres zuordnen, für die du verantwortlich bist. Wenn du keine Genres auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
"tooltipSpecialAssignmentShort": "Specials zu hochgeladenen Songs zuordnen",
"tooltipSpecialAssignmentLong": "Wähle ein oder mehrere Specials vor dem Upload aus. Die ausgewählten Specials werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Specials zuordnen, für die du verantwortlich bist. Wenn du keine Specials auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
"tooltipTracklistShort": "Deine Songs verwalten",
"tooltipTracklistLong": "Diese Tabelle zeigt alle Songs in deinen Genres und Specials. Du kannst suchen, filtern, sortieren und Songs bearbeiten. Nutze die Checkboxen, um mehrere Songs für die Batch-Bearbeitung auszuwählen. Nur Songs, die du bearbeiten kannst, haben eine aktive Checkbox.",
"tooltipSearchShort": "Nach Titel oder Artist suchen",
"tooltipSearchLong": "Tippe in das Suchfeld, um Songs nach Titel oder Artist-Namen zu filtern. Die Suche ist nicht case-sensitive und findet Teiltexte. Leere das Suchfeld, um alle Songs wieder anzuzeigen.",
"tooltipFilterShort": "Nach Genre, Special oder Global-Flag filtern",
"tooltipFilterLong": "Nutze das Filter-Dropdown, um nur Songs aus einem bestimmten Genre, Special oder Songs, die von der globalen Playlist ausgeschlossen sind, anzuzeigen. Kombiniere mit der Suche für präzisere Filterung.",
"tooltipBatchEditShort": "Mehrere Songs gleichzeitig bearbeiten",
"tooltipBatchEditLong": "Wähle mehrere Songs über Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden. Du kannst Genres/Specials umschalten, Artist-Namen ändern oder das Exclude-Global-Flag ändern (nur Global-Kuratoren).",
"tooltipBatchGenreToggleShort": "Genres hinzufügen oder entfernen",
"tooltipBatchGenreToggleLong": "Wähle Genres zum Umschalten aus. Wenn ein ausgewählter Song das Genre bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Dies ermöglicht es dir, schnell Genres zu mehreren Songs hinzuzufügen oder zu entfernen.",
"tooltipBatchSpecialToggleShort": "Specials hinzufügen oder entfernen",
"tooltipBatchSpecialToggleLong": "Wähle Specials zum Umschalten aus. Wenn ein ausgewählter Song das Special bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Du kannst nur Specials umschalten, für die du verantwortlich bist.",
"tooltipBatchArtistShort": "Artist für alle ausgewählten Songs ändern",
"tooltipBatchArtistLong": "Gib einen neuen Artist-Namen ein, um ihn für alle ausgewählten Songs zu setzen. Dies ist nützlich, um Artist-Namen zu korrigieren oder Namenskonventionen über mehrere Songs hinweg zu standardisieren.",
"tooltipCommentsShort": "Spieler-Feedback und Kommentare",
"tooltipCommentsLong": "Spieler können dir Nachrichten zu Rätseln in deinen Genres oder Specials senden. Ungelesene Kommentare sind mit einem blauen Rahmen und Badge hervorgehoben. Klicke auf einen Kommentar, um ihn als gelesen zu markieren, oder archiviere ihn, wenn du ihn nicht mehr benötigst.",
"tooltipCurateSpecialsShort": "Startzeiten für deine Specials kuratieren",
"tooltipCurateSpecialsLong": "In dieser Ansicht siehst du alle Specials, die dir zugewiesen sind. Öffne ein Special, um den Audio-Ausschnitt zu wählen, den die Spieler hören. Du kannst nur Specials sehen und bearbeiten, für die du zuständig bist.",
"tooltipCurateSpecialEditorShort": "Mit dem Waveform-Editor den Puzzle-Ausschnitt wählen",
"tooltipCurateSpecialEditorLong": "Klicke auf die Waveform, um zu bestimmen, wo das Rätsel startet. Nutze Zoom und Pan für Feineinstellungen und spiele einzelne Segmente ab, um sie zu testen. Beim Speichern wird nur dieser kuratierte Ausschnitt für Spieler in diesem Special verwendet."
},
"About": {
"title": "Über Hördle & Impressum",
@@ -162,15 +410,15 @@
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
"imprintCountry": "Deutschland",
"imprintEmailLabel": "E-Mail:",
"imprintDisclaimer": "Hinweis: Diese Angaben entsprechen dem aktuellen Stand. Für rechtliche Fragen solltest du eine Fachperson konsultieren.",
"costsTitle": "Laufende Kosten des Projekts",
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
"costsDonationNote": "Alle Einnahmen, die die Betriebskosten des Projekts übersteigen, werden am Jahresende an die Aktion <link>Zentrum für politische Schönheit</link> gespendet.",
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
"costsServer": "Server / vServer für App und Tracking",
"costsEmail": "E-Mail-Hosting",
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
"costsSheetPrivacyNote": "Beim Aufruf der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
"supportTitle": "Hördle unterstützen",
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
@@ -180,6 +428,10 @@
"supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
"supportCuratorTitle": "Als Kurator bewerben",
"supportCuratorText": "Du hast gute Kenntnisse in einem Genre und möchtest dich als Kurator bewerben? Wir freuen uns über deine Nachricht!",
"supportReportBugTitle": "Fehler melden",
"supportReportBugText": "Fehler in der App gefunden? Bitte melde sie per E-Mail an <email>admin@hoerdle.de</email>.",
"privacyTitle": "Datenschutz",
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
@@ -190,9 +442,8 @@
"privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).",
"privacyServerLogs": "Der Server kann technisch bedingt Logdateien mit IP-Adresse, Zeitpunkt des Zugriffs und abgerufenen Ressourcen führen. Diese Daten werden nur zur Sicherstellung des Betriebs und zur Fehleranalyse verwendet und regelmäßig gelöscht.",
"privacyContact": "Wenn du Fragen zu den verarbeiteten Daten hast oder Auskunft wünschst, kannst du dich über die im Impressum genannte E-Mail-Adresse melden.",
"privacyNoLegalAdvice": "Hinweis: Diese Datenschutzhinweise dienen nur als Beispiel und ersetzen keine rechtliche Beratung. Für eine rechtskonforme Datenschutzerklärung solltest du eine Fachperson konsultieren.",
"backTitle": "Zurück zum Spiel",
"backToGame": "Zurück zu Hördle",
"footerLinkLabel": "Über & Impressum"
}
}
}

View File

@@ -1,156 +1,404 @@
{
"Common": {
"loading": "Loading...",
"error": "An error occurred",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"back": "Back"
},
"Navigation": {
"home": "Home",
"admin": "Admin",
"global": "Global",
"news": "News"
},
"Game": {
"play": "Play",
"pause": "Pause",
"skip": "Skip",
"submit": "Guess",
"next": "Next",
"won": "You won!",
"lost": "Game Over",
"correct": "Correct!",
"wrong": "Wrong",
"guessPlaceholder": "Type song or artist...",
"attempts": "Attempts",
"share": "Share",
"nextPuzzle": "Next puzzle in",
"noPuzzleAvailable": "No Puzzle Available",
"noPuzzleDescription": "Could not generate a daily puzzle.",
"noPuzzleGenre": "Please ensure there are songs in the database",
"goToAdmin": "Go to Admin Dashboard",
"loadingState": "Loading state...",
"attempt": "Attempt",
"unlocked": "unlocked",
"start": "Start",
"skipWithBonus": "Skip (+{seconds}s)",
"solveGiveUp": "Solve (Give Up)",
"comeBackTomorrow": "Come back tomorrow for a new song.",
"theSongWas": "The song was:",
"score": "Score",
"scoreBreakdown": "Score Breakdown",
"albumCover": "Album Cover",
"released": "Released",
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
"thanksForRating": "Thanks for rating!",
"rateThisPuzzle": "Rate this puzzle:",
"shared": "✓ Shared!",
"copied": "✓ Copied!",
"shareFailed": "✗ Failed",
"bonusRound": "Bonus Round!",
"guessReleaseYear": "Guess the release year for",
"points": "points",
"skipBonus": "Skip Bonus",
"notQuite": "Not quite!",
"youGuessed": "You guessed",
"actuallyReleasedIn": "Actually released in",
"skipped": "Skipped",
"gameOverPlaceholder": "Game Over",
"knowItSearch": "Know it? Search for the artist / title",
"special": "Special",
"genre": "Genre"
},
"Statistics": {
"yourStatistics": "Your Statistics",
"totalPuzzles": "Total puzzles",
"try": "try",
"failed": "Failed"
},
"OnboardingTour": {
"done": "Done",
"next": "Next",
"previous": "Previous",
"genresSpecials": "Genres & Specials",
"genresSpecialsDescription": "Choose a specific genre or a curated special event here.",
"news": "News",
"newsDescription": "Stay updated with the latest news and announcements.",
"hoerdle": "Hördle",
"hoerdleDescription": "This is the daily puzzle. One new song every day per genre.",
"attempts": "Attempts",
"attemptsDescription": "You have a limited number of attempts to guess the song.",
"score": "Score",
"scoreDescription": "Your current score. Try to keep it high!",
"player": "Player",
"playerDescription": "Listen to the snippet. Each additional play reduces your potential score.",
"input": "Input",
"inputDescription": "Type your guess here. Search for artist or title.",
"controls": "Controls",
"controlsDescription": "Start the music or skip to the next snippet if you're stuck."
},
"InstallPrompt": {
"installApp": "Install Hördle App",
"installDescription": "Install the app for a better experience and quick access!",
"iosInstructions": "Tap",
"iosShare": "share",
"iosThen": "then \"Add to Home Screen\"",
"installButton": "Install App"
},
"Home": {
"welcome": "Welcome to Hördle",
"subtitle": "Guess the song from short snippets",
"globalTooltip": "A random song from the entire collection",
"comingSoon": "Coming soon",
"curatedBy": "Curated by"
},
"Admin": {
"title": "Hördle Admin Dashboard",
"login": "Admin Login",
"password": "Password",
"loginButton": "Login",
"logout": "Logout",
"manageSpecials": "Manage Specials",
"manageGenres": "Manage Genres",
"manageNews": "Manage News & Announcements",
"uploadSongs": "Upload Songs",
"todaysPuzzles": "Today's Daily Puzzles",
"show": "▶ Show",
"hide": "▼ Hide",
"addSpecial": "Add Special",
"addGenre": "Add Genre",
"addNews": "Add News",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"curate": "Curate",
"name": "Name",
"subtitle": "Subtitle",
"maxAttempts": "Max Attempts",
"unlockSteps": "Unlock Steps",
"launchDate": "Launch Date",
"endDate": "End Date",
"curator": "Curator",
"active": "Active",
"newGenreName": "New Genre Name",
"editSpecial": "Edit Special",
"editGenre": "Edit Genre",
"editNews": "Edit News",
"newsTitle": "News Title",
"content": "Content (Markdown supported)",
"author": "Author (optional)",
"featured": "Featured",
"noSpecialLink": "No Special Link",
"noNewsItems": "No news items yet. Create one above!",
"noPuzzlesToday": "No daily puzzles found for today.",
"category": "Category",
"song": "Song",
"artist": "Artist",
"actions": "Actions",
"deletePuzzle": "Delete",
"wrongPassword": "Wrong password"
"Common": {
"loading": "Loading...",
"error": "An error occurred",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"back": "Back"
},
"Navigation": {
"home": "Home",
"admin": "Admin",
"global": "Global",
"news": "News"
},
"Game": {
"play": "Play",
"pause": "Pause",
"skip": "Skip",
"submit": "Guess",
"next": "Next",
"won": "You won!",
"lost": "Game Over",
"correct": "Correct!",
"wrong": "Wrong",
"guessPlaceholder": "Type song or artist...",
"attempts": "Attempts",
"share": "Share",
"nextPuzzle": "Next puzzle in",
"noPuzzleAvailable": "No Puzzle Available",
"noPuzzleDescription": "Could not generate a daily puzzle.",
"noPuzzleGenre": "Please ensure there are songs in the database",
"goToAdmin": "Go to Admin Dashboard",
"loadingState": "Loading state...",
"attempt": "Attempt",
"unlocked": "unlocked",
"start": "Start",
"skipWithBonus": "Skip (+{seconds}s)",
"solveGiveUp": "Solve (Give Up)",
"comeBackTomorrow": "Come back tomorrow for a new song.",
"theSongWas": "The song was:",
"score": "Score",
"shareExplanation": "Share your result with friends your support helps Hördle grow.",
"scoreBreakdown": "Score Breakdown",
"albumCover": "Album Cover",
"released": "Released",
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
"thanksForRating": "Thanks for rating!",
"rateThisPuzzle": "Rate this puzzle:",
"ratingTooltip": "Help our curators create good puzzles!",
"shared": "✓ Shared!",
"copied": "✓ Copied!",
"shareFailed": "✗ Failed",
"bonusRound": "Bonus Round!",
"guessReleaseYear": "Guess the release year for",
"points": "points",
"skipBonus": "Skip Bonus",
"notQuite": "Not quite!",
"sendComment": "Send message to curator",
"sendCommentCollapsed": "Send message to curator",
"commentPlaceholder": "Write a message to the curators of this genre... Please remain friendly and polite.",
"commentHelp": "Share your thoughts on the puzzle with the curators. Your message will be shown to them.",
"commentAIConsent": "I agree that this message will be processed by an AI to filter unfriendly messages.",
"commentSent": "✓ Message sent! Thank you for your feedback.",
"commentThankYou": "Thank you for your feedback!",
"commentRewritten": "Your message was automatically rephrased to be more friendly:",
"commentError": "Error sending message",
"commentRateLimited": "You have already sent a message for this puzzle.",
"sending": "Sending...",
"youGuessed": "You guessed",
"actuallyReleasedIn": "Actually released in",
"skipped": "Skipped",
"gameOverPlaceholder": "Game Over",
"knowItSearch": "Know it? Search for the artist / title",
"special": "Special",
"genre": "Genre"
},
"ExtraPuzzles": {
"title": "Still in the mood for puzzles?",
"message": "Hey, would you like to try some more puzzles? Then take a look at {name}!",
"cta": "Go to {name}",
"close": "Close"
},
"Statistics": {
"yourStatistics": "Your Statistics",
"totalPuzzles": "Total puzzles",
"try": "try",
"failed": "Failed"
},
"OnboardingTour": {
"done": "Done",
"next": "Next",
"previous": "Previous",
"genresSpecials": "Genres & Specials",
"genresSpecialsDescription": "Choose a specific genre or a curated special event here.",
"news": "News",
"newsDescription": "Stay updated with the latest news and announcements.",
"hoerdle": "Hördle",
"hoerdleDescription": "This is the daily puzzle. One new song every day per genre.",
"attempts": "Attempts",
"attemptsDescription": "You have a limited number of attempts to guess the song.",
"score": "Score",
"scoreDescription": "Your current score. Try to keep it high!",
"player": "Player",
"playerDescription": "Listen to the snippet. Each additional play reduces your potential score.",
"input": "Input",
"inputDescription": "Type your guess here. Search for artist or title.",
"controls": "Controls",
"controlsDescription": "Start the music or skip to the next snippet if you're stuck."
},
"InstallPrompt": {
"installApp": "Install Hördle App",
"installDescription": "Install the app for a better experience and quick access!",
"iosInstructions": "Tap",
"iosShare": "share",
"iosThen": "then \"Add to Home Screen\"",
"installButton": "Install App"
},
"Home": {
"welcome": "Welcome to Hördle",
"subtitle": "Guess the song from short snippets",
"globalTooltip": "A random song from the entire collection",
"comingSoon": "Coming soon",
"curatedBy": "Curated by"
},
"Admin": {
"title": "Hördle Admin Dashboard",
"login": "Admin Login",
"password": "Password",
"loginButton": "Login",
"logout": "Logout",
"manageSpecials": "Manage Specials",
"manageGenres": "Manage Genres",
"manageNews": "Manage News & Announcements",
"uploadSongs": "Upload Songs",
"todaysPuzzles": "Today's Daily Puzzles",
"show": "▶ Show",
"hide": "▼ Hide",
"addSpecial": "Add Special",
"addGenre": "Add Genre",
"addNews": "Add News",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"curate": "Curate",
"name": "Name",
"subtitle": "Subtitle",
"maxAttempts": "Max Attempts",
"unlockSteps": "Unlock Steps",
"unlockStepsRequired": "Unlock steps are required",
"unlockStepsInvalidJson": "Invalid JSON format. Please use an array of numbers, e.g. [2,4,7,11,16,30,60]",
"unlockStepsMustBeArray": "Unlock steps must be an array",
"unlockStepsMustBePositiveNumbers": "All values must be positive numbers",
"launchDate": "Launch Date",
"endDate": "End Date",
"curator": "Curator",
"active": "Active",
"newGenreName": "New Genre Name",
"editSpecial": "Edit Special",
"editGenre": "Edit Genre",
"editNews": "Edit News",
"newsTitle": "News Title",
"content": "Content (Markdown supported)",
"author": "Author (optional)",
"featured": "Featured",
"noSpecialLink": "No Special Link",
"noNewsItems": "No news items yet. Create one above!",
"noPuzzlesToday": "No daily puzzles found for today.",
"category": "Category",
"song": "Song",
"artist": "Artist",
"actions": "Actions",
"deletePuzzle": "Delete",
"wrongPassword": "Wrong password",
"manageCurators": "Manage Curators",
"addCurator": "Add Curator",
"curatorUsername": "Username",
"curatorPassword": "Password (leave empty to keep)",
"isGlobalCurator": "Global curator (may change global flag)",
"assignedGenres": "Assigned genres",
"assignedSpecials": "Assigned specials",
"noCurators": "No curators created yet."
},
"Curator": {
"loginTitle": "Curator Login",
"loginUsername": "Username",
"loginPassword": "Password",
"loginButton": "Log in",
"logout": "Logout",
"loginFailed": "Login failed.",
"loginNetworkError": "Network error during login.",
"loadCuratorError": "Failed to load curator information.",
"loadSongsError": "Failed to load songs.",
"songUpdated": "Song updated successfully.",
"saveError": "Error while saving: {error}",
"saveNetworkError": "Network error while saving.",
"noDeletePermission": "You are not allowed to delete this song.",
"deleteConfirm": "Do you really want to delete \"{title}\"?",
"songDeleted": "Song deleted.",
"deleteError": "Error while deleting: {error}",
"deleteNetworkError": "Network error while deleting.",
"uploadSectionTitle": "Upload titles",
"uploadSectionDescription": "Drag one or more MP3 files here or select them. The titles will be analysed automatically (including detection of the release year) and excluded from the global playlist. Select at least one of your genres to assign the titles.",
"dropzoneTitleEmpty": "Drag MP3 files here",
"dropzoneTitleWithFiles": "{count} file(s) selected",
"dropzoneSubtitle": "or click to select files",
"selectedFilesTitle": "Selected files:",
"uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Assign genres",
"assignSpecialsLabel": "Assign specials",
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
"uploadButtonIdle": "Start upload",
"uploadButtonUploading": "Uploading...",
"uploadSummary": "✅ {success}/{total} uploads successful.",
"uploadSummaryDuplicates": "⚠️ {count} duplicate(s) skipped.",
"uploadSummaryFailed": "❌ {count} failed.",
"uploadResultSuccess": "✅ successful",
"uploadResultDuplicate": "⚠️ Duplicate: {error}",
"uploadResultError": "❌ Error: {error}",
"tracklistTitle": "Titles in your genres & specials ({count} titles)",
"tracklistDescription": "You can edit songs that are assigned to at least one of your genres or specials. Deletion is only allowed if a song is assigned exclusively to your genres/specials. Genres, specials, news and political statements can only be managed by the admin.",
"searchPlaceholder": "Search by title or artist...",
"filterAll": "All content",
"filterNoGlobal": "🚫 No global",
"filterReset": "Reset filters",
"noSongsInScope": "No matching songs in your genres/specials.",
"columnId": "ID",
"columnPlay": "Play",
"columnTitle": "Title",
"columnArtist": "Artist",
"columnYear": "Year",
"columnCover": "Cover",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Added",
"columnActivations": "Activations",
"columnRating": "Rating",
"columnExcludeGlobal": "Exclude global",
"columnActions": "Actions",
"play": "Play",
"pause": "Pause",
"excludeGlobalYes": "Yes",
"excludeGlobalNo": "No",
"excludeGlobalInfo": "Only global curators may change this flag.",
"paginationPrev": "Previous",
"paginationNext": "Next",
"paginationLabel": "Page {page} of {total}",
"loadingData": "Loading data...",
"loggedInAs": "Logged in as {username}",
"globalCuratorSuffix": " (Global curator)",
"pageSizeLabel": "Per page:",
"commentsTitle": "Comments",
"showComments": "Show comments",
"hideComments": "Hide comments",
"loadingComments": "Loading comments...",
"noComments": "No comments available.",
"loadCommentsError": "Error loading comments.",
"commentFromPuzzle": "Comment from puzzle",
"commentGenre": "Genre",
"unreadComment": "Unread",
"archiveComment": "Archive",
"archiveCommentConfirm": "Do you really want to archive this comment?",
"archiveCommentError": "Error archiving comment.",
"newComments": "new",
"batchEditTitle": "Batch Edit",
"clearSelection": "Clear Selection",
"batchToggleGenres": "Toggle Genres",
"batchToggleSpecials": "Toggle Specials",
"batchChangeArtist": "Change Artist",
"batchArtistPlaceholder": "Enter new artist name",
"batchExcludeGlobal": "Exclude from Global",
"batchNoChange": "No change",
"batchExclude": "Exclude",
"batchInclude": "Include",
"batchUpdating": "Updating...",
"batchApply": "Apply Changes",
"selectAll": "Select all",
"selectSong": "Select song",
"cannotEditSong": "Cannot edit this song",
"noSongsSelected": "No songs selected",
"noBatchOperations": "No batch operations specified",
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
"batchUpdateError": "Error: {error}",
"batchUpdateNetworkError": "Network error during batch update",
"backToDashboard": "Back to dashboard",
"loading": "Loading...",
"curateSpecialsButton": "Curate Specials",
"curateSpecialsTitle": "Curate your Specials",
"curateSpecialsDescription": "Here you can fine-tune the start times of the songs in your assigned specials for the puzzle.",
"noSpecialPermissions": "You do not have any specials assigned to you.",
"noSpecialsInScope": "No specials available for you to curate.",
"noSpecialsAssigned": "No specials assigned to you.",
"curateSpecialSongCount": "{count, plural, one {# song} other {# songs}} in this special",
"curateSpecialOpen": "Open",
"specialForbidden": "You are not allowed to edit this special.",
"specialNotFound": "Special not found.",
"backToCuratorSpecials": "Back to specials overview",
"curateSpecialHeaderPrefix": "Curate Special:",
"curateSpecialNoSongs": "No songs assigned to this special yet.",
"curateSpecialNoSongsSub": "Go back to your dashboard to add songs to this special.",
"curateSpecialInstructions": "Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.",
"saving": "💾 Saving...",
"saveChanges": "💾 Save Changes",
"saved": "✓ Saved"
},
"CuratorHelp": {
"title": "Curator Help & Manual",
"backToDashboard": "Back to Dashboard",
"helpButton": "Help",
"modalTitle": "Help",
"introductionTitle": "Introduction",
"introductionText": "As a curator, you are responsible for managing songs within your assigned genres and specials. This dashboard allows you to upload, edit, and organize music for the Hördle game.",
"permissionsTitle": "Your Permissions",
"permission1": "Upload MP3 files and assign them to your genres",
"permission2": "Edit songs that are assigned to at least one of your genres or specials",
"permission3": "Delete songs that are exclusively assigned to your genres/specials",
"permission4": "View and manage comments from players about your puzzles",
"note": "Note",
"permissionNote": "You can only edit or delete songs that are assigned to your genres/specials. Songs assigned to other curators' genres cannot be modified by you.",
"uploadTitle": "Uploading Songs",
"uploadStepsTitle": "Step-by-Step Guide",
"uploadStep1": "Drag MP3 files into the upload area or click to select files",
"uploadStep2": "Select one or more genres and, if applicable, specials to assign to the uploaded songs",
"uploadStep3": "Click 'Start upload' to begin the upload process",
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
"uploadBestPracticesTitle": "Best Practices",
"uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction",
"uploadBestPractice2": "Select appropriate genres (and specials) before uploading to avoid manual assignment later",
"uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists",
"tip": "Tip",
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",
"editingTitle": "Editing Songs",
"singleEditTitle": "Single Song Editing",
"singleEditText": "Click the edit button (✏️) next to a song to modify its title, artist, release year, genres, specials, or exclude-from-global flag. Only songs you can edit will have an active edit button.",
"batchEditTitle": "Batch Editing",
"batchEditText": "Select multiple songs using the checkboxes, then use the batch edit toolbar to apply changes to all selected songs at once:",
"batchEditFeature1": "Genre Toggle: Add or remove genres from all selected songs",
"batchEditFeature2": "Special Toggle: Add or remove specials from all selected songs",
"batchEditFeature3": "Artist Change: Set the same artist name for all selected songs",
"batchEditFeature4": "Exclude Global Flag: Set or remove the exclude-from-global flag (Global Curators only)",
"genreSpecialAssignmentTitle": "Genre & Special Assignment",
"genreSpecialAssignmentText": "You can only assign songs to genres and specials that you are responsible for. Songs can have multiple genres and specials. When editing, you can toggle assignments - if a genre/special is already assigned, it will be removed; if not, it will be added.",
"commentsTitle": "Managing Comments",
"commentsText": "Players can send you feedback about puzzles in your genres or specials. Comments appear in your dashboard with a badge showing unread messages.",
"commentsActionsTitle": "Available Actions",
"markAsRead": "Mark as Read",
"markAsReadText": "Click on a comment to mark it as read. Read comments are displayed with a gray border.",
"archive": "Archive",
"archiveText": "Archive comments you no longer need. Archived comments are removed from your view.",
"bestPracticesTitle": "Best Practices for Curators",
"bestPractice1": "Keep metadata accurate: Ensure song titles and artist names are correct and consistent",
"bestPractice2": "Use appropriate genres: Assign songs to the most relevant genres to help players discover music",
"bestPractice3": "Respond to comments: Check comments regularly and consider player feedback when curating",
"bestPractice4": "Maintain quality: Review uploaded songs for audio quality and metadata accuracy",
"bestPractice5": "Use batch editing efficiently: When making similar changes to multiple songs, use batch edit to save time",
"troubleshootingTitle": "Troubleshooting",
"troubleshootingQ1": "Why can't I edit a song?",
"troubleshootingA1": "You can only edit songs that are assigned to at least one of your genres or specials. If a song has no genres/specials assigned, you can edit it. If it's assigned to other curators' genres only, you cannot edit it.",
"troubleshootingQ2": "Why can't I delete a song?",
"troubleshootingA2": "You can only delete songs that are exclusively assigned to your genres/specials. If a song has any genres or specials assigned to other curators, you cannot delete it.",
"troubleshootingQ3": "Why can't I assign a genre/special?",
"troubleshootingA3": "You can only assign songs to genres and specials that you are responsible for. Contact the admin if you need access to additional genres or specials.",
"troubleshootingQ4": "Why is the exclude-from-global checkbox disabled?",
"troubleshootingA4": "Only Global Curators can change the exclude-from-global flag. If you need this permission, contact the admin.",
"curateSpecialsHelpTitle": "Curating specials",
"curateSpecialsHelpIntro": "In the \"Curate Specials\" area you can choose the exact audio snippet that players will hear in your specials. You only ever see specials that are assigned to you.",
"curateSpecialsHelpStepsTitle": "How to curate specials",
"curateSpecialsHelpStep1": "Open the curator dashboard and click on \"Curate Specials\" to see all specials assigned to you.",
"curateSpecialsHelpStep2": "Select a special from the list to open the waveform editor for that special.",
"curateSpecialsHelpStep3": "Click on the waveform to choose the start time. The highlighted region shows exactly what players will hear.",
"curateSpecialsHelpStep4": "Use zoom, pan and segment playback to fine-tune the snippet. Click \"Save changes\" to apply the new start time.",
"curateSpecialsPermissionsNote": "You can only curate specials that are assigned to you. If you try to open or save a special that is not yours, the system will block the action.",
"tooltipDashboardShort": "Overview of your curator dashboard",
"tooltipDashboardLong": "This is your main dashboard where you can upload songs, manage your track list, and view comments from players. Use the help button (❓) to access the full manual.",
"tooltipUploadShort": "Upload MP3 files to your genres",
"tooltipUploadLong": "Drag and drop MP3 files or click to select. The system will automatically extract metadata (title, artist, release year) from ID3 tags. Select genres before uploading to automatically assign songs. All curator uploads are excluded from the global playlist by default.",
"tooltipGenreAssignmentShort": "Assign genres to uploaded songs",
"tooltipGenreAssignmentLong": "Select one or more genres before uploading. The selected genres will be assigned to all successfully uploaded songs. You can only assign genres that you are responsible for. If you don't select any genres, you can assign them later by editing the songs.",
"tooltipSpecialAssignmentShort": "Assign specials to uploaded songs",
"tooltipSpecialAssignmentLong": "Select one or more specials before uploading. The selected specials will be assigned to all successfully uploaded songs. You can only assign specials that you are responsible for. If you don't select any specials, you can assign them later by editing the songs.",
"tooltipTracklistShort": "Manage your songs",
"tooltipTracklistLong": "This table shows all songs in your genres and specials. You can search, filter, sort, and edit songs. Use the checkboxes to select multiple songs for batch editing. Only songs you can edit will have an active checkbox.",
"tooltipSearchShort": "Search by title or artist",
"tooltipSearchLong": "Type in the search box to filter songs by title or artist name. The search is case-insensitive and matches partial text. Clear the search to show all songs again.",
"tooltipFilterShort": "Filter by genre, special, or global flag",
"tooltipFilterLong": "Use the filter dropdown to show only songs from a specific genre, special, or songs excluded from the global playlist. Combine with search for more precise filtering.",
"tooltipBatchEditShort": "Edit multiple songs at once",
"tooltipBatchEditLong": "Select multiple songs using checkboxes, then use the batch edit toolbar to apply changes to all selected songs simultaneously. You can toggle genres/specials, change artist names, or modify the exclude-from-global flag (Global Curators only).",
"tooltipBatchGenreToggleShort": "Add or remove genres",
"tooltipBatchGenreToggleLong": "Select genres to toggle. If a selected song already has the genre, it will be removed. If it doesn't have the genre, it will be added. This allows you to quickly add or remove genres from multiple songs at once.",
"tooltipBatchSpecialToggleShort": "Add or remove specials",
"tooltipBatchSpecialToggleLong": "Select specials to toggle. If a selected song already has the special, it will be removed. If it doesn't have the special, it will be added. You can only toggle specials you are responsible for.",
"tooltipBatchArtistShort": "Change artist for all selected songs",
"tooltipBatchArtistLong": "Enter a new artist name to set it for all selected songs. This is useful for correcting artist names or standardizing naming conventions across multiple songs.",
"tooltipCommentsShort": "Player feedback and comments",
"tooltipCommentsLong": "Players can send you messages about puzzles in your genres or specials. Unread comments are highlighted with a blue border and badge. Click on a comment to mark it as read, or archive it if you no longer need it.",
"tooltipCurateSpecialsShort": "Curate the start times for your specials",
"tooltipCurateSpecialsLong": "This view shows all specials that are assigned to you. Open a special to choose the audio snippet that players will hear. You can only see and edit specials for which you are responsible.",
"tooltipCurateSpecialEditorShort": "Use the waveform editor to pick the puzzle snippet",
"tooltipCurateSpecialEditorLong": "Click on the waveform to choose where the puzzle starts. Use zoom and pan for fine control, and play back individual segments to test them. When you save, only this curated snippet will be used for players in this special."
},
"About": {
"title": "About Hördle & Imprint",
@@ -162,15 +410,15 @@
"imprintOperator": "Responsible for the content of this site (provider under German law):",
"imprintCountry": "Germany",
"imprintEmailLabel": "Email:",
"imprintDisclaimer": "Note: This information is current as of the date indicated. For legal questions you should consult a legal professional.",
"costsTitle": "Ongoing costs of the project",
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
"costsDonationNote": "All income that exceeds the operating costs of the project will be donated at the end of the year to the campaign <link>Zentrum für politische Schönheit</link>.",
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
"costsServer": "Servers / vServers for the app and tracking",
"costsEmail": "Email hosting",
"costsLicenses": "Possible fees for copyrights or other licenses",
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
"costsSheetPrivacyNote": "When accessing the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
"supportTitle": "Support Hördle",
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
@@ -180,6 +428,10 @@
"supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regular support via Steady",
"supportCuratorTitle": "Apply as Curator",
"supportCuratorText": "Do you have good knowledge in a genre and would like to apply as a curator? We'd be happy to hear from you!",
"supportReportBugTitle": "Report Bugs",
"supportReportBugText": "Found a bug in the app? Please report it via email to <email>admin@hoerdle.de</email>.",
"privacyTitle": "Privacy",
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",
@@ -190,9 +442,8 @@
"privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).",
"privacyServerLogs": "For technical reasons, the server may log IP address, time of access and accessed resources. This data is only used to keep the service running and to debug issues and is deleted on a regular basis.",
"privacyContact": "If you have questions about the data processed or want to request information, please contact the email address given in the imprint.",
"privacyNoLegalAdvice": "Note: These privacy notes are only an example and do not replace legal advice. For a legally compliant privacy policy you should consult a professional.",
"backTitle": "Back to the game",
"backToGame": "Back to Hördle",
"footerLinkLabel": "About & Imprint"
}
}
}

100
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "hoerdle",
"version": "0.1.0.15",
"version": "0.1.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoerdle",
"version": "0.1.0.15",
"version": "0.1.6.11",
"dependencies": {
"@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2",
"next": "16.0.3",
"next": "^16.0.7",
"next-intl": "^4.5.6",
"prisma": "^6.19.0",
"react": "19.2.0",
@@ -28,7 +28,7 @@
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"eslint-config-next": "^16.0.7",
"typescript": "^5"
}
},
@@ -1101,15 +1101,15 @@
}
},
"node_modules/@next/env": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz",
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.3.tgz",
"integrity": "sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.7.tgz",
"integrity": "sha512-hFrTNZcMEG+k7qxVxZJq3F32Kms130FAhG8lvw2zkKBgAcNOJIxlljNiCjGygvBshvaGBdf88q2CqWtnqezDHA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1117,9 +1117,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz",
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
"cpu": [
"arm64"
],
@@ -1133,9 +1133,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz",
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
"cpu": [
"x64"
],
@@ -1149,9 +1149,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz",
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
"cpu": [
"arm64"
],
@@ -1165,9 +1165,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz",
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
"cpu": [
"arm64"
],
@@ -1181,9 +1181,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz",
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
"cpu": [
"x64"
],
@@ -1197,9 +1197,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz",
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
"cpu": [
"x64"
],
@@ -1213,9 +1213,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz",
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
"cpu": [
"arm64"
],
@@ -1229,9 +1229,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz",
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
"cpu": [
"x64"
],
@@ -3474,13 +3474,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.3.tgz",
"integrity": "sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.7.tgz",
"integrity": "sha512-WubFGLFHfk2KivkdRGfx6cGSFhaQqhERRfyO8BRx+qiGPGp7WLKcPvYC4mdx1z3VhVRcrfFzczjjTrbJZOpnEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.0.3",
"@next/eslint-plugin-next": "16.0.7",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -5945,12 +5945,12 @@
}
},
"node_modules/next": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==",
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
"license": "MIT",
"dependencies": {
"@next/env": "16.0.3",
"@next/env": "16.0.7",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -5963,14 +5963,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.3",
"@next/swc-darwin-x64": "16.0.3",
"@next/swc-linux-arm64-gnu": "16.0.3",
"@next/swc-linux-arm64-musl": "16.0.3",
"@next/swc-linux-x64-gnu": "16.0.3",
"@next/swc-linux-x64-musl": "16.0.3",
"@next/swc-win32-arm64-msvc": "16.0.3",
"@next/swc-win32-x64-msvc": "16.0.3",
"@next/swc-darwin-arm64": "16.0.7",
"@next/swc-darwin-x64": "16.0.7",
"@next/swc-linux-arm64-gnu": "16.0.7",
"@next/swc-linux-arm64-musl": "16.0.7",
"@next/swc-linux-x64-gnu": "16.0.7",
"@next/swc-linux-x64-musl": "16.0.7",
"@next/swc-win32-arm64-msvc": "16.0.7",
"@next/swc-win32-x64-msvc": "16.0.7",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.1",
"version": "0.1.6.28",
"private": true,
"scripts": {
"dev": "next dev",
@@ -13,7 +13,7 @@
"bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2",
"next": "16.0.3",
"next": "^16.0.7",
"next-intl": "^4.5.6",
"prisma": "^6.19.0",
"react": "19.2.0",
@@ -29,7 +29,7 @@
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"eslint-config-next": "^16.0.7",
"typescript": "^5"
}
}
}

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "PlayerState" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"identifier" TEXT NOT NULL,
"genreKey" TEXT NOT NULL,
"gameState" TEXT NOT NULL,
"statistics" TEXT NOT NULL,
"lastPlayed" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE INDEX "PlayerState_identifier_idx" ON "PlayerState"("identifier");
-- CreateIndex
CREATE UNIQUE INDEX "PlayerState_identifier_genreKey_key" ON "PlayerState"("identifier", "genreKey");

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "PoliticalStatement" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"locale" TEXT NOT NULL,
"text" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"source" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "PoliticalStatement_locale_active_idx" ON "PoliticalStatement"("locale", "active");

View File

@@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "Curator" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"isGlobalCurator" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "CuratorGenre" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"curatorId" INTEGER NOT NULL,
"genreId" INTEGER NOT NULL,
CONSTRAINT "CuratorGenre_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorGenre_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CuratorSpecial" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"curatorId" INTEGER NOT NULL,
"specialId" INTEGER NOT NULL,
CONSTRAINT "CuratorSpecial_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorSpecial_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Curator_username_key" ON "Curator"("username");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorGenre_curatorId_genreId_key" ON "CuratorGenre"("curatorId", "genreId");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorSpecial_curatorId_specialId_key" ON "CuratorSpecial"("curatorId", "specialId");

View File

@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "CuratorComment" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"playerIdentifier" TEXT NOT NULL,
"puzzleId" INTEGER NOT NULL,
"genreId" INTEGER,
"message" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CuratorComment_puzzleId_fkey" FOREIGN KEY ("puzzleId") REFERENCES "DailyPuzzle" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorComment_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CuratorCommentRecipient" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"commentId" INTEGER NOT NULL,
"curatorId" INTEGER NOT NULL,
"readAt" DATETIME,
CONSTRAINT "CuratorCommentRecipient_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "CuratorComment" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorCommentRecipient_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "CuratorComment_playerIdentifier_puzzleId_key" ON "CuratorComment"("playerIdentifier", "puzzleId");
-- CreateIndex
CREATE INDEX "CuratorComment_genreId_idx" ON "CuratorComment"("genreId");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorCommentRecipient_commentId_curatorId_key" ON "CuratorCommentRecipient"("commentId", "curatorId");
-- CreateIndex
CREATE INDEX "CuratorCommentRecipient_curatorId_idx" ON "CuratorCommentRecipient"("curatorId");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CuratorCommentRecipient" ADD COLUMN "archived" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Special" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" JSONB NOT NULL,
"subtitle" JSONB,
"maxAttempts" INTEGER NOT NULL DEFAULT 7,
"unlockSteps" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"launchDate" DATETIME,
"endDate" DATETIME,
"curator" TEXT,
"hidden" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Special" ("createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "name", "subtitle", "unlockSteps") SELECT "createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "name", "subtitle", "unlockSteps" FROM "Special";
DROP TABLE "Special";
ALTER TABLE "new_Special" RENAME TO "Special";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -33,6 +33,8 @@ model Genre {
active Boolean @default(true)
songs Song[]
dailyPuzzles DailyPuzzle[]
curatorGenres CuratorGenre[]
comments CuratorComment[]
}
model Special {
@@ -45,9 +47,11 @@ model Special {
launchDate DateTime?
endDate DateTime?
curator String?
hidden Boolean @default(false)
songs SpecialSong[]
puzzles DailyPuzzle[]
news News[]
curatorSpecials CuratorSpecial[]
}
model SpecialSong {
@@ -71,6 +75,7 @@ model DailyPuzzle {
genre Genre? @relation(fields: [genreId], references: [id])
specialId Int?
special Special? @relation(fields: [specialId], references: [id])
comments CuratorComment[]
@@unique([date, genreId, specialId])
}
@@ -88,3 +93,92 @@ model News {
@@index([publishedAt])
}
model PlayerState {
id Int @id @default(autoincrement())
identifier String // UUID des Spielers (für Cross-Domain-Sync)
genreKey String // Genre-Name oder "global" oder "special:<name>"
gameState String // JSON-String des GameState
statistics String // JSON-String der Statistics
lastPlayed DateTime @updatedAt
createdAt DateTime @default(now())
@@unique([identifier, genreKey])
@@index([identifier])
}
model Curator {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
isGlobalCurator Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
genres CuratorGenre[]
specials CuratorSpecial[]
commentRecipients CuratorCommentRecipient[]
}
model CuratorGenre {
id Int @id @default(autoincrement())
curatorId Int
genreId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
@@unique([curatorId, genreId])
}
model CuratorSpecial {
id Int @id @default(autoincrement())
curatorId Int
specialId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
@@unique([curatorId, specialId])
}
model PoliticalStatement {
id Int @id @default(autoincrement())
locale String
text String
active Boolean @default(true)
source String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([locale, active])
}
model CuratorComment {
id Int @id @default(autoincrement())
playerIdentifier String
puzzleId Int
puzzle DailyPuzzle @relation(fields: [puzzleId], references: [id], onDelete: Cascade)
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id], onDelete: SetNull)
message String
createdAt DateTime @default(now())
recipients CuratorCommentRecipient[]
@@unique([playerIdentifier, puzzleId])
@@index([genreId])
}
model CuratorCommentRecipient {
id Int @id @default(autoincrement())
commentId Int
comment CuratorComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
curatorId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
readAt DateTime?
archived Boolean @default(false)
@@unique([commentId, curatorId])
@@index([curatorId])
}

View File

@@ -2,9 +2,9 @@ import createMiddleware from 'next-intl/middleware';
import type { NextRequest } from 'next/server';
const i18nMiddleware = createMiddleware({
locales: ['de', 'en'],
defaultLocale: 'de',
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`)
locales: ['en', 'de'],
defaultLocale: 'en',
// Wir nutzen überall Locale-Präfixe (`/en`, `/de`)
localePrefix: 'always'
});
@@ -21,16 +21,41 @@ export default function proxy(request: NextRequest) {
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Extract Plausible domain from script URL for CSP
const plausibleScriptSrc = process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js';
let plausibleOrigin = 'https://plausible.example.com';
try {
const url = new URL(plausibleScriptSrc);
plausibleOrigin = url.origin;
} catch {
// If URL parsing fails, try to extract domain manually
const match = plausibleScriptSrc.match(/https?:\/\/([^/]+)/);
if (match) {
plausibleOrigin = `https://${match[1]}`;
}
}
// Get other service URLs from environment (only add to CSP if configured)
const gotifyUrl = process.env.GOTIFY_URL;
const openrouterUrl = process.env.NEXT_PUBLIC_OPENROUTER_URL || 'https://openrouter.ai';
// Build CSP dynamically based on environment variables
const connectSrcParts = ["'self'", openrouterUrl, plausibleOrigin];
if (gotifyUrl && !gotifyUrl.includes('example.com')) {
connectSrcParts.push(gotifyUrl);
}
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me",
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${plausibleOrigin}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
`connect-src ${connectSrcParts.join(' ')}`,
"media-src 'self' blob:",
"frame-ancestors 'self'",
].join('; ');
headers.set('Content-Security-Policy', csp);
return response;

BIN
public/favicon-base.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Some files were not shown because too many files have changed in this diff Show More