Compare commits

...

52 Commits

Author SHA1 Message Date
Hördle Bot
9bf7e72a6c Fix: Properly handle async play() and remove autoPlay conflict 2025-11-25 15:28:22 +01:00
Hördle Bot
f8b5dcf300 Fix: Start button now actually starts audio playback 2025-11-25 15:26:20 +01:00
Hördle Bot
072158f4ed Feature: Skip button becomes Start button on first attempt if audio not played 2025-11-25 15:18:25 +01:00
Hördle Bot
898d2f5959 Add NewsSection to genre and special pages 2025-11-25 14:22:07 +01:00
Hördle Bot
a7aec80f39 Fix: Link special in news section 2025-11-25 13:59:32 +01:00
Hördle Bot
0e313db2e3 Implement News System with Admin UI and Homepage Integration 2025-11-25 11:52:52 +01:00
Hördle Bot
3e647cd44b Fix version API to read version.txt directly 2025-11-25 10:22:12 +01:00
Hördle Bot
54af256e91 feat: Enhance Docker build versioning with a build argument, fetch git tags during deployment, and add comprehensive deployment documentation. 2025-11-25 10:15:47 +01:00
Hördle Bot
ce413cf6bc feat: Implement Docker version reporting by extracting git tag to an environment variable for API consumption. 2025-11-25 09:41:50 +01:00
Hördle Bot
5102ca86cb feat: Add batch genre assignment functionality to song uploads, including UI for selection and post-upload API calls. 2025-11-25 09:34:55 +01:00
Hördle Bot
eb3d2c86d7 feat: Extract footer into a new component and add dynamic application version display via a new API route. 2025-11-25 09:20:01 +01:00
Hördle Bot
883875b82a docs: Update README with additional sortable library fields, enhanced sharing options, and genre activation/deactivation. 2025-11-25 00:33:07 +01:00
Hördle Bot
4c13817e77 feat: conditionally display 'Special' or 'Genre' for the genre text based on isSpecial flag 2025-11-25 00:29:31 +01:00
Hördle Bot
35fe5f2d44 feat: Add sorting by activations and average rating to admin page and include bonus star in game share text. 2025-11-25 00:27:08 +01:00
Hördle Bot
70501d626b feat: Add genre validation with 404 for inactive genres and filter genre list to active ones. 2025-11-25 00:23:05 +01:00
Hördle Bot
41ce6c12ce feat: Implement genre activation/deactivation with UI controls and main page filtering. 2025-11-25 00:20:29 +01:00
Hördle Bot
a744393335 feat: remove iTunes release year refresh API endpoint and UI from admin page 2025-11-25 00:09:28 +01:00
Hördle Bot
0ee3a48770 refactor: simplify year guessed display condition. 2025-11-25 00:06:32 +01:00
Hördle Bot
187774bce7 feat: Add NoGlobal feature to exclude songs from Global Daily Puzzle 2025-11-24 20:23:07 +01:00
Hördle Bot
67cf85dc22 feat(song): add option to exclude songs from global visibility and improve admin upload validation 2025-11-24 19:59:47 +01:00
Hördle Bot
326023a705 feat: remove MusicBrainz integration and exclusively use iTunes for song release years 2025-11-24 18:53:03 +01:00
Hördle Bot
41e2ec1495 feat: Add rate limiting and request serialization to iTunes API calls. 2025-11-24 18:47:25 +01:00
Hördle Bot
62402d7000 Remove cleanSearchTerm calls for artist and title from within the retry loop. 2025-11-24 15:39:45 +01:00
Hördle Bot
0599c066d9 feat: Log cleaned artist and title used for iTunes search. 2025-11-24 15:39:29 +01:00
Hördle Bot
f7de7f2684 feat: clean artist and title terms before iTunes search to improve result accuracy. 2025-11-24 15:37:45 +01:00
Hördle Bot
e5d06029ef feat: Add slow-refresh-itunes.js for robust iTunes year updates and remove migrate-covers.mjs from docker-compose. 2025-11-24 15:27:52 +01:00
Hördle Bot
e8e0aa27fb fix: update User-Agent and add Accept and Accept-Language headers for iTunes fetch. 2025-11-24 14:40:34 +01:00
Hördle Bot
7f455053e7 fix: Improve iTunes API call success rate by increasing rate limit delay and adding a User-Agent header. 2025-11-24 14:36:27 +01:00
Hördle Bot
3309b5c5ee feat: implement iTunes API for release year detection and bulk refresh 2025-11-24 14:23:07 +01:00
Hördle Bot
cd30476349 Fix bonus year question spoiler: hide release year until after bonus question 2025-11-24 10:33:27 +01:00
Hördle Bot
cd19a6c04d Reduce verbose logging in cover migration script 2025-11-24 09:58:49 +01:00
Hördle Bot
7011a24b46 Fix: Add missing fetchSpecials() calls to update special song counts 2025-11-24 09:56:56 +01:00
Hördle Bot
9a98830245 Add skip logic to migration scripts to prevent re-running 2025-11-24 09:50:19 +01:00
Hördle Bot
3630745169 Merge security-audit-improvements: comprehensive security enhancements 2025-11-24 09:46:16 +01:00
Hördle Bot
831adcaf17 Add logout function and ADMIN_PASSWORD environment validation 2025-11-24 09:42:58 +01:00
Hördle Bot
2d6481a42f Security audit improvements: authentication, path traversal protection, file validation, rate limiting, security headers 2025-11-24 09:34:54 +01:00
Hördle Bot
0f7d66c619 Add automatic baseline fallback to docker-compose command 2025-11-23 22:49:02 +01:00
Hördle Bot
bc36a09b81 Add baseline script for production database migration history 2025-11-23 22:47:36 +01:00
Hördle Bot
a71afa4f6f Fix: Add release year migration to docker-compose command chain 2025-11-23 22:43:37 +01:00
Hördle Bot
ceae0266b8 Fix: Enhance release year migration with fuzzy search, more retries, and detailed logging 2025-11-23 22:35:10 +01:00
Hördle Bot
27ed9eedb2 Fix: Improve release year migration reliability (retry on restart, better logging, slower rate limit) 2025-11-23 22:29:24 +01:00
Hördle Bot
015862ce0c Fix: Prevent bonus round reappearance on reload and enable autoplay after wrong guess 2025-11-23 22:23:51 +01:00
Hördle Bot
4d807c77d0 Fix: Remove .release-years-migrated from git to ensure migration runs on deployment 2025-11-23 20:37:48 +01:00
Hördle Bot
7b975dc3e3 Finalize scoring system, release year integration, and fix song deletion 2025-11-23 20:37:23 +01:00
Hördle Bot
e5b0512884 chore: remove unnecessary console logs 2025-11-23 17:17:21 +01:00
Hördle Bot
e9a8c41a7d feat: Add descriptions for duplicate detection and intelligent artist recognition to Admin Dashboard features. 2025-11-23 16:08:46 +01:00
Hördle Bot
d280106336 Add duplicate detection with fuzzy matching and fix artist metadata extraction 2025-11-23 16:06:55 +01:00
Hördle Bot
d75910ecc5 docs: Add iFrame embedding instructions to README. 2025-11-23 15:38:53 +01:00
Hördle Bot
d09dbece5f Fix upcoming specials display on genre pages 2025-11-23 15:35:34 +01:00
Hördle Bot
80e6066c17 Add subtitles to Genres and Specials 2025-11-23 15:20:12 +01:00
Hördle Bot
b8321cef56 Show favicon as fallback when cover image is missing 2025-11-23 14:43:35 +01:00
Hördle Bot
57affff7d1 feat: Add database backup with rotation to deploy script. 2025-11-23 10:52:05 +01:00
51 changed files with 4390 additions and 204 deletions

View File

@@ -0,0 +1,47 @@
---
description: Add subtitles to Genres and Specials
---
# Implementation Plan - Add Subtitles to Genres and Specials
The goal is to add a `subtitle` field to both `Genre` and `Special` models, allowing administrators to provide descriptions. These subtitles will be displayed as tooltips on the homepage.
## 1. Database Schema Changes
- [ ] Modify `prisma/schema.prisma`:
- Add `subtitle String?` to the `Genre` model.
- Add `subtitle String?` to the `Special` model.
- [ ] Create a migration: `npx prisma migrate dev --name add_subtitles`
## 2. Backend API Updates
- [ ] Update `app/api/genres/route.ts`:
- Update `POST` to accept `subtitle`.
- Add `PUT` method to allow updating genre name and subtitle.
- [ ] Update `app/api/specials/route.ts`:
- Update `POST` to accept `subtitle`.
- Update `PUT` to accept `subtitle`.
## 3. Admin UI Updates
- [ ] Update `app/admin/page.tsx`:
- **Genres**:
- Update the "Add Genre" form to include an input for `subtitle`.
- Add an "Edit" button for each genre.
- Implement a form/modal to edit genre name and subtitle.
- Display the subtitle in the list of genres.
- **Specials**:
- Update the "Create Special" form to include an input for `subtitle`.
- Update the "Edit Special" form (in the conditional rendering) to include `subtitle`.
- [ ] Update `app/admin/specials/[id]/page.tsx`:
- Update the display to show the subtitle under the title.
## 4. Frontend Updates
- [ ] Update `app/page.tsx`:
- Fetch `subtitle` for genres and specials (already covered by `findMany`).
- Add a tooltip to the links.
- For `Link` components, we can use the `title` attribute for a native tooltip, or build a custom CSS tooltip. The user asked for "gut lesbarer Tooltip" (readable tooltip). Native `title` is often small and delayed. A custom CSS tooltip (using a group/hover pattern) would be better.
- I will implement a simple CSS-based tooltip component or style.
## 5. Verification
- [ ] Verify database migration.
- [ ] Verify creating a genre with a subtitle.
- [ ] Verify creating/editing a special with a subtitle.
- [ ] Verify tooltips on the homepage.

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ next-env.d.ts
/public/uploads/*
!/public/uploads/.gitkeep
/data
.release-years-migrated
.covers-migrated

185
DEBUG_VERSION.md Normal file
View File

@@ -0,0 +1,185 @@
# Debug Version Display - Remote Server Checklist
## 1. Überprüfe Git-Tags auf dem Remote-Server
```bash
# Im Projekt-Verzeichnis auf dem Remote-Server
cd /path/to/hoerdle
# Zeige alle Tags
git tag -l
# Zeige aktuellen Tag/Version
git describe --tags --always
# Wenn keine Tags angezeigt werden:
git fetch --tags
git describe --tags --always
```
**Erwartetes Ergebnis:** Sollte `v0.1.0.2` oder ähnlich zeigen
---
## 2. Überprüfe die version.txt im Container
```bash
# Zeige den Inhalt der Version-Datei im laufenden Container
docker exec hoerdle cat /app/version.txt
# Sollte die Version zeigen, z.B. "v0.1.0.2"
```
**Erwartetes Ergebnis:** Die aktuelle Version, nicht "unknown" oder "dev"
---
## 3. Überprüfe die Umgebungsvariable im Container
```bash
# Zeige alle Umgebungsvariablen
docker exec hoerdle env | grep APP_VERSION
# Sollte APP_VERSION=v0.1.0.2 oder ähnlich zeigen
```
**Erwartetes Ergebnis:** `APP_VERSION=v0.1.0.2`
---
## 4. Überprüfe die Container-Logs beim Start
```bash
# Zeige die letzten Logs beim Container-Start
docker logs hoerdle | head -20
# Suche speziell nach Version-Ausgaben
docker logs hoerdle | grep -i version
```
**Erwartetes Ergebnis:** Eine Zeile wie "App version: v0.1.0.2"
---
## 5. Teste die API direkt
```bash
# Rufe die Version-API auf
curl http://localhost:3010/api/version
# Sollte JSON zurückgeben: {"version":"v0.1.0.2"}
```
**Erwartetes Ergebnis:** `{"version":"v0.1.0.2"}`
---
## 6. Überprüfe wann der Container gebaut wurde
```bash
# Zeige Image-Informationen
docker images | grep hoerdle
# Zeige detaillierte Container-Informationen
docker inspect hoerdle | grep -i created
```
**Wichtig:** Wenn das Image vor deinem letzten Deployment erstellt wurde, wurde es noch nicht neu gebaut!
---
## 7. Überprüfe Build-Logs
```bash
# Baue das Image neu und beobachte die Ausgabe
docker compose build --no-cache 2>&1 | tee build.log
# Suche nach der Version-Ausgabe im Build
grep -i "Building version" build.log
```
**Erwartetes Ergebnis:** Eine Zeile wie "Building version: v0.1.0.2"
---
## Häufige Probleme und Lösungen
### Problem 1: Tags nicht auf dem Server
```bash
git fetch --tags
git describe --tags --always
```
### Problem 2: Container wurde nicht neu gebaut
```bash
docker compose build --no-cache
docker compose up -d
```
### Problem 3: Alte version.txt im Container
```bash
# Stoppe Container, lösche Image, baue neu
docker compose down
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
docker compose build --no-cache
docker compose up -d
```
### Problem 4: .git Verzeichnis nicht im Build-Context
```bash
# Überprüfe ob .git existiert
ls -la .git
# Überprüfe .dockerignore (sollte .git NICHT ausschließen)
cat .dockerignore 2>/dev/null || echo "Keine .dockerignore Datei"
```
---
## Vollständiger Neustart (wenn nichts anderes hilft)
```bash
# 1. Stoppe alles
docker compose down
# 2. Lösche alte Images
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
# 3. Hole neueste Änderungen und Tags
git pull
git fetch --tags
# 4. Überprüfe Version lokal
git describe --tags --always
# 5. Baue komplett neu
docker compose build --no-cache
# 6. Starte Container
docker compose up -d
# 7. Überprüfe Logs
docker logs hoerdle | grep -i version
# 8. Teste API
curl http://localhost:3010/api/version
```
---
## Debugging-Befehl für alle Checks auf einmal
```bash
echo "=== Git Tags ===" && \
git describe --tags --always && \
echo -e "\n=== version.txt im Container ===" && \
docker exec hoerdle cat /app/version.txt 2>/dev/null || echo "Container läuft nicht oder Datei fehlt" && \
echo -e "\n=== APP_VERSION Env ===" && \
docker exec hoerdle env | grep APP_VERSION || echo "Variable nicht gesetzt" && \
echo -e "\n=== API Response ===" && \
curl -s http://localhost:3010/api/version && \
echo -e "\n\n=== Container Created ===" && \
docker inspect hoerdle | grep -i created | head -1
```
Kopiere diesen Befehl und führe ihn auf dem Remote-Server aus. Schicke mir die Ausgabe!

84
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,84 @@
# Deployment Guide
## Automated Deployment
Use the deployment script for zero-downtime deployments:
```bash
./scripts/deploy.sh
```
This script will:
1. Create a database backup
2. Pull latest changes from git
3. Fetch all git tags (for version display)
4. Build the new Docker image
5. Restart the container with minimal downtime
6. Clean up old images
## Manual Deployment
If you need to deploy manually:
```bash
# Pull latest changes
git pull
# Fetch tags (important for version display!)
git fetch --tags
# Build and restart
docker compose build
docker compose up -d
```
## Version Display
The app displays the current version in the footer. The version is determined as follows:
1. **During Docker build**: The version is extracted from git tags using `git describe --tags --always`
2. **At runtime**: The version is read from `/app/version.txt` and exposed via the `/api/version` endpoint
3. **Local development**: The version is extracted directly from git on each request
### Building with a specific version
You can override the version during build:
```bash
docker compose build --build-arg APP_VERSION=v1.2.3
```
### Troubleshooting
If the version shows as "dev" or "unknown":
1. Make sure git tags are pushed to the remote repository:
```bash
git push --tags
```
2. On the deployment server, fetch the tags:
```bash
git fetch --tags
```
3. Verify tags are available:
```bash
git describe --tags --always
```
4. Rebuild the Docker image:
```bash
docker compose build --no-cache
docker compose up -d
```
## Health Check
The container includes a health check that monitors the `/api/daily` endpoint. Check the health status:
```bash
docker ps
```
Look for the "healthy" status in the STATUS column.

View File

@@ -13,9 +13,24 @@ RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Accept version as build argument (optional)
ARG APP_VERSION=""
# Install git to extract version information
RUN apk add --no-cache git
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Extract version: use build arg if provided, otherwise get from git
RUN if [ -n "$APP_VERSION" ]; then \
echo "$APP_VERSION" > /tmp/version.txt; \
else \
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
fi && \
echo "Building version: $(cat /tmp/version.txt)"
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
@@ -53,6 +68,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
# Create uploads directory and set permissions
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
# Copy version file from builder
COPY --from=builder /tmp/version.txt /app/version.txt
USER nextjs
EXPOSE 3000

117
README.md
View File

@@ -8,20 +8,26 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
- **Admin Dashboard:**
- Upload von MP3-Dateien.
- **Duplikatserkennung:** Automatische Erkennung von bereits vorhandenen Songs mit Fuzzy-Matching (toleriert Variationen wie "AC/DC" vs "AC DC").
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
- Bearbeitung von Metadaten.
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am).
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
- **Cover Art:**
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
- Automatische Migration bestehender Songs.
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden.
- **Teilen-Funktion:**
- Ergebnisse können als Emoji-Grid geteilt werden.
- Stern-Symbol (⭐) bei korrekt beantworteter Bonusfrage.
- Automatische Anpassung für Genre- und Special-Rätsel.
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
- **Genre-Management:**
- Erstellen und Verwalten von Musik-Genres.
- **Aktivierung/Deaktivierung:** Genres können aktiviert oder deaktiviert werden (deaktivierte Genres sind nicht auf der Startseite sichtbar und ihre Routen sind nicht erreichbar).
- Manuelle Zuweisung von Genres zu Songs.
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
- Genre-spezifische tägliche Rätsel.
@@ -35,7 +41,28 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Live-Vorschau beim Hovern über die Waveform.
- Playback-Cursor zeigt aktuelle Abspielposition.
- Einzelne Segmente zum Testen abspielen.
- Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung.
- **News & Announcements:**
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
- **Markdown Support:** Formatierung von Texten, Links und Listen.
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
- **Special-Verknüpfung:** Direkte Links zu Specials in News-Beiträgen.
- Verwaltung über das Admin-Dashboard.
## Spielregeln & Punktesystem
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.
- **Start-Punktestand:** 90 Punkte
- **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte
- **Ü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.
## Tech Stack
@@ -154,6 +181,92 @@ server {
Eine vollständige Beispiel-Konfiguration findest du in `nginx.conf.example`.
## iFrame-Einbindung
Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die App ist responsive und passt sich automatisch an die iFrame-Größe an.
### Grundlegende Einbindung
```html
<iframe
src="https://hoerdle.elpatron.me"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle - Daily Music Quiz">
</iframe>
```
### Genre-spezifische Einbindung
Einzelne Genres können direkt eingebunden werden:
```html
<!-- Rock Genre -->
<iframe
src="https://hoerdle.elpatron.me/Rock"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Rock Quiz">
</iframe>
<!-- Pop Genre -->
<iframe
src="https://hoerdle.elpatron.me/Pop"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Pop Quiz">
</iframe>
```
### Special-Einbindung
Auch thematische Specials können direkt eingebettet werden:
```html
<iframe
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Weihnachts-Special">
</iframe>
```
### Empfohlene Einstellungen
- **Mindesthöhe:** 800px (damit alle Elemente sichtbar sind)
- **Breite:** 100% oder mindestens 600px
- **`allow="autoplay"`:** Erforderlich für Audio-Wiedergabe
- **Responsive:** Die App passt sich automatisch an mobile Geräte an
### Beispiel mit responsiver Höhe
```html
<div style="position: relative; padding-bottom: 133%; height: 0; overflow: hidden;">
<iframe
src="https://hoerdle.elpatron.me"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder="0"
allow="autoplay"
title="Hördle">
</iframe>
</div>
```
### Hinweise
- Der Spielfortschritt wird im LocalStorage des iFrames gespeichert
- Nutzer können innerhalb des iFrames zwischen Genres wechseln (Navigation bleibt erhalten)
- Die Teilen-Funktion funktioniert auch im iFrame
- Für beste Performance sollte der iFrame auf derselben Domain wie die Hauptseite gehostet werden (vermeidet CORS-Probleme)
## Troubleshooting
### Audio-Dateien lassen sich nicht abspielen (in Produktion mit Nginx)

View File

@@ -1,7 +1,9 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
import { notFound } from 'next/navigation';
export const dynamic = 'force-dynamic';
@@ -14,10 +16,34 @@ interface PageProps {
export default async function GenrePage({ params }: PageProps) {
const { genre } = await params;
const decodedGenre = decodeURIComponent(genre);
// Check if genre exists and is active
const currentGenre = await prisma.genre.findUnique({
where: { name: decodedGenre }
});
if (!currentGenre || !currentGenre.active) {
notFound();
}
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date();
const activeSpecials = specials.filter(s => {
const isStarted = !s.launchDate || s.launchDate <= now;
const isEnded = s.endDate && s.endDate < now;
return isStarted && !isEnded;
});
const upcomingSpecials = specials.filter(s => {
return s.launchDate && s.launchDate > now;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
@@ -40,12 +66,12 @@ export default async function GenrePage({ params }: PageProps) {
))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
{activeSpecials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
@@ -59,7 +85,25 @@ export default async function GenrePage({ params }: PageProps) {
</Link>
))}
</div>
{/* Upcoming Specials */}
{upcomingSpecials.length > 0 && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Coming soon: {upcomingSpecials.map(s => (
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
{s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
</span>
))}
</div>
)}
</div>
<NewsSection />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
</>
);

View File

@@ -3,13 +3,14 @@
const GOTIFY_URL = process.env.GOTIFY_URL;
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) {
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null, score?: number) {
try {
const genreText = genre ? `[${genre}] ` : '';
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
const scoreText = score !== undefined ? ` with a score of ${score}` : '';
const message = status === 'won'
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).`
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`;
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s)${scoreText}.`
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s)${scoreText}.`;
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST',

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ interface SpecialSong {
interface Special {
id: number;
name: string;
subtitle?: string;
maxAttempts: number;
unlockSteps: string;
songs: SpecialSong[];
@@ -139,6 +140,11 @@ export default function SpecialEditorPage() {
<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>

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -63,6 +64,10 @@ export async function GET() {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { puzzleId } = await request.json();

View File

@@ -1,7 +1,12 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { rateLimit } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
// Rate limiting: 5 login attempts per minute
const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 5 });
if (rateLimitError) return rateLimitError;
export async function POST(request: Request) {
try {
const { password } = await request.json();
// Default is hash for 'admin123'

View File

@@ -8,8 +8,28 @@ export async function GET(
) {
try {
const { filename } = await params;
// Security: Prevent path traversal attacks
// Only allow alphanumeric, hyphens, underscores, and dots
const safeFilenamePattern = /^[a-zA-Z0-9_\-\.]+\.mp3$/;
if (!safeFilenamePattern.test(filename)) {
return new NextResponse('Invalid filename', { status: 400 });
}
// Additional check: ensure no path separators
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
return new NextResponse('Invalid filename', { status: 400 });
}
const filePath = path.join(process.cwd(), 'public/uploads', filename);
// Security: Verify the resolved path is still within uploads directory
const uploadsDir = path.join(process.cwd(), 'public/uploads');
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(uploadsDir)) {
return new NextResponse('Forbidden', { status: 403 });
}
// Check if file exists
try {
await stat(filePath);

View File

@@ -1,6 +1,7 @@
'use server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -16,6 +17,10 @@ interface CategorizeResult {
}
export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
if (!OPENROUTER_API_KEY) {
return Response.json(

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -21,15 +22,23 @@ export async function GET() {
}
export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { name } = await request.json();
const { name, subtitle, active } = await request.json();
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
}
const genre = await prisma.genre.create({
data: { name: name.trim() },
data: {
name: name.trim(),
subtitle: subtitle ? subtitle.trim() : null,
active: active !== undefined ? active : true
},
});
return NextResponse.json(genre);
@@ -40,6 +49,10 @@ export async function POST(request: Request) {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { id } = await request.json();
@@ -57,3 +70,31 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { id, name, subtitle, active } = await request.json();
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const genre = await prisma.genre.update({
where: { id: Number(id) },
data: {
...(name && { name: name.trim() }),
subtitle: subtitle ? subtitle.trim() : null,
...(active !== undefined && { active })
},
});
return NextResponse.json(genre);
} catch (error) {
console.error('Error updating genre:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

146
app/api/news/route.ts Normal file
View File

@@ -0,0 +1,146 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
// GET /api/news - Public endpoint to fetch news
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '10');
const featuredOnly = searchParams.get('featured') === 'true';
const where = featuredOnly ? { featured: true } : {};
const news = await prisma.news.findMany({
where,
orderBy: { publishedAt: 'desc' },
take: limit,
include: {
special: {
select: {
id: true,
name: true
}
}
}
});
return NextResponse.json(news);
} catch (error) {
console.error('Error fetching news:', error);
return NextResponse.json({ error: 'Failed to fetch news' }, { status: 500 });
}
}
// POST /api/news - Create news (requires auth)
export async function POST(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { title, content, author, featured, specialId } = body;
if (!title || !content) {
return NextResponse.json(
{ error: 'Title and content are required' },
{ status: 400 }
);
}
const news = await prisma.news.create({
data: {
title,
content,
author: author || null,
featured: featured || false,
specialId: specialId || null
},
include: {
special: {
select: {
id: true,
name: true
}
}
}
});
return NextResponse.json(news, { status: 201 });
} catch (error) {
console.error('Error creating news:', error);
return NextResponse.json({ error: 'Failed to create news' }, { status: 500 });
}
}
// PUT /api/news - Update news (requires auth)
export async function PUT(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { id, title, content, author, featured, specialId } = body;
if (!id) {
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
}
const updateData: any = {};
if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content;
if (author !== undefined) updateData.author = author || null;
if (featured !== undefined) updateData.featured = featured;
if (specialId !== undefined) updateData.specialId = specialId || null;
const news = await prisma.news.update({
where: { id },
data: updateData,
include: {
special: {
select: {
id: true,
name: true
}
}
}
});
return NextResponse.json(news);
} catch (error) {
console.error('Error updating news:', error);
return NextResponse.json({ error: 'Failed to update news' }, { status: 500 });
}
}
// DELETE /api/news - Delete news (requires auth)
export async function DELETE(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) {
return authError;
}
try {
const body = await request.json();
const { id } = body;
if (!id) {
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
}
await prisma.news.delete({
where: { id }
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting news:', error);
return NextResponse.json({ error: 'Failed to delete news' }, { status: 500 });
}
}

View File

@@ -3,9 +3,15 @@ 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';
const prisma = new PrismaClient();
// Configure route to handle large file uploads
export const runtime = 'nodejs';
export const maxDuration = 60; // 60 seconds timeout for uploads
export async function GET() {
const songs = await prisma.song.findMany({
orderBy: { createdAt: 'desc' },
@@ -28,29 +34,70 @@ export async function GET() {
filename: song.filename,
createdAt: song.createdAt,
coverImage: song.coverImage,
releaseYear: song.releaseYear,
activations: song.puzzles.length,
puzzles: song.puzzles,
genres: song.genres,
specials: song.specials.map(ss => ss.special),
averageRating: song.averageRating,
ratingCount: song.ratingCount,
excludeFromGlobal: song.excludeFromGlobal,
}));
return NextResponse.json(songsWithActivations);
}
export async function POST(request: Request) {
console.log('[UPLOAD] Starting song upload request');
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) {
console.log('[UPLOAD] Authentication failed');
return authError;
}
try {
console.log('[UPLOAD] Parsing form data...');
const formData = await request.formData();
const file = formData.get('file') as File;
let title = '';
let artist = '';
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
if (!file) {
console.error('[UPLOAD] No file provided');
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Security: Validate file size (max 50MB)
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({
error: `File too large. Maximum size is 50MB, got ${(file.size / 1024 / 1024).toFixed(2)}MB`
}, { status: 400 });
}
// Security: Validate MIME type
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3'];
if (!allowedMimeTypes.includes(file.type)) {
return NextResponse.json({
error: `Invalid file type. Expected MP3, got ${file.type}`
}, { status: 400 });
}
// Security: Validate file extension
if (!file.name.toLowerCase().endsWith('.mp3')) {
return NextResponse.json({
error: 'Invalid file extension. Only .mp3 files are allowed'
}, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
// Validate and extract metadata from file
let metadata;
@@ -72,8 +119,16 @@ export async function POST(request: Request) {
if (metadata.common.title) {
title = metadata.common.title;
}
if (metadata.common.artist) {
// Handle artist - prefer artists array if available
if (metadata.common.artists && metadata.common.artists.length > 0) {
// Join multiple artists with '/'
artist = metadata.common.artists.join('/');
} else if (metadata.common.artist) {
artist = metadata.common.artist;
} else if (metadata.common.albumartist) {
// Fallback to album artist
artist = metadata.common.albumartist;
}
// Validation info
@@ -114,6 +169,28 @@ export async function POST(request: Request) {
if (!title) title = 'Unknown Title';
if (!artist) artist = 'Unknown Artist';
// Check for duplicates
const existingSongs = await prisma.song.findMany({
select: { id: true, title: true, artist: true, filename: true }
});
for (const existing of existingSongs) {
if (isDuplicateSong(artist, title, existing.artist, existing.title)) {
return NextResponse.json(
{
error: 'Duplicate song detected',
duplicate: {
id: existing.id,
title: existing.title,
artist: existing.artist,
filename: existing.filename
}
},
{ status: 409 }
);
}
}
// Create URL-safe filename
const originalName = file.name.replace(/\.mp3$/i, '');
const sanitizedName = originalName
@@ -148,12 +225,27 @@ 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);
if (releaseYear) {
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
}
} catch (e) {
console.error('Failed to fetch release year:', e);
}
const song = await prisma.song.create({
data: {
title,
artist,
filename,
coverImage,
releaseYear,
excludeFromGlobal,
},
include: { genres: true, specials: true }
});
@@ -169,8 +261,12 @@ 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;
try {
const { id, title, artist, genreIds, specialIds } = await request.json();
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
@@ -178,6 +274,15 @@ export async function PUT(request: Request) {
const data: any = { title, artist };
// Update releaseYear if provided (can be null to clear it)
if (releaseYear !== undefined) {
data.releaseYear = releaseYear;
}
if (excludeFromGlobal !== undefined) {
data.excludeFromGlobal = excludeFromGlobal;
}
if (genreIds) {
data.genres = {
set: genreIds.map((gId: number) => ({ id: gId }))
@@ -239,6 +344,10 @@ 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;
try {
const { id } = await request.json();

View File

@@ -1,5 +1,6 @@
import { PrismaClient, Special } from '@prisma/client';
import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -16,13 +17,18 @@ export async function GET() {
}
export async function POST(request: Request) {
const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
// Check authentication
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();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const special = await prisma.special.create({
data: {
name,
subtitle: subtitle || null,
maxAttempts: Number(maxAttempts),
unlockSteps,
launchDate: launchDate ? new Date(launchDate) : null,
@@ -34,6 +40,10 @@ export async function POST(request: Request) {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
@@ -43,7 +53,11 @@ export async function DELETE(request: Request) {
}
export async function PUT(request: Request) {
const { id, name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
@@ -51,6 +65,7 @@ export async function PUT(request: Request) {
where: { id: Number(id) },
data: {
...(name && { name }),
subtitle: subtitle || null, // Allow clearing or setting
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
...(unlockSteps && { unlockSteps }),
launchDate: launchDate ? new Date(launchDate) : null,

65
app/api/version/route.ts Normal file
View File

@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
export async function GET() {
try {
// First check if version file exists (Docker deployment)
// Try both /app/version.txt (Docker) and ./version.txt (local)
const versionPaths = [
'/app/version.txt',
join(process.cwd(), 'version.txt')
];
for (const versionFilePath of versionPaths) {
if (existsSync(versionFilePath)) {
const version = readFileSync(versionFilePath, 'utf-8').trim();
if (version && version !== 'unknown') {
return NextResponse.json({ version });
}
}
}
// Fallback: check environment variable
if (process.env.APP_VERSION) {
return NextResponse.json({ version: process.env.APP_VERSION });
}
// Fallback: try to get from git (local development)
let version = 'dev';
try {
// First try to get the exact tag if we're on a tagged commit
version = execSync('git describe --tags --exact-match 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
} catch {
try {
// If not on a tag, get the latest tag with commit info
version = execSync('git describe --tags --always 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
} catch {
// If git is not available or no tags exist, try to get commit hash
try {
const hash = execSync('git rev-parse --short HEAD 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
version = `dev-${hash}`;
} catch {
// Fallback to just 'dev' if git is not available
version = 'dev';
}
}
}
return NextResponse.json({ version });
} catch (error) {
console.error('Error getting version:', error);
return NextResponse.json({ version: 'unknown' });
}
}

View File

@@ -410,4 +410,50 @@ body {
font-size: 1.25rem;
font-weight: bold;
color: #000;
}
/* Tooltip */
.tooltip {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 200px;
background-color: #333;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 100;
top: 100%;
left: 50%;
margin-left: -100px;
margin-top: 5px;
opacity: 0;
transition: opacity 0.3s;
font-size: 0.75rem;
font-weight: normal;
pointer-events: none;
line-height: 1.2;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
.tooltip .tooltip-text::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent #333 transparent;
}

View File

@@ -25,6 +25,7 @@ export const viewport: Viewport = {
};
import InstallPrompt from "@/components/InstallPrompt";
import AppFooter from "@/components/AppFooter";
export default function RootLayout({
children,
@@ -36,15 +37,7 @@ export default function RootLayout({
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<InstallPrompt />
<footer className="app-footer">
<p>
Vibe coded with and 🍺 by{' '}
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
@elpatron@digitalcourage.social
</a>
{' '}- for personal use among friends only!
</p>
</footer>
<AppFooter />
</body>
</html>
);

View File

@@ -1,4 +1,5 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
@@ -9,7 +10,10 @@ const prisma = new PrismaClient();
export default async function Home() {
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date();
@@ -28,13 +32,19 @@ export default async function Home() {
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
<div className="tooltip">
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
<span className="tooltip-text">A random song from the entire collection</span>
</div>
{/* Genres */}
{genres.map(g => (
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
<div key={g.id} className="tooltip">
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
</div>
))}
{/* Separator if both exist */}
@@ -45,16 +55,19 @@ export default async function Home() {
{/* Active Specials */}
{activeSpecials.map(s => (
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Link
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
<div className="tooltip">
<Link
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
</div>
{s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}>
Curated by {s.curator}
@@ -81,6 +94,9 @@ export default async function Home() {
</div>
)}
</div>
<NewsSection />
<Game dailyPuzzle={dailyPuzzle} genre={null} />
</>
);

View File

@@ -1,4 +1,5 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
@@ -94,6 +95,7 @@ export default async function SpecialPage({ params }: PageProps) {
))}
</div>
</div>
<NewsSection />
<Game
dailyPuzzle={dailyPuzzle}
genre={decodedName}

34
components/AppFooter.tsx Normal file
View File

@@ -0,0 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
export default function AppFooter() {
const [version, setVersion] = useState<string>('');
useEffect(() => {
fetch('/api/version')
.then(res => res.json())
.then(data => setVersion(data.version))
.catch(() => setVersion(''));
}, []);
return (
<footer className="app-footer">
<p>
Vibe coded with and 🍺 by{' '}
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
@elpatron@digitalcourage.social
</a>
{' '}- for personal use among friends only!
{version && (
<>
{' '}·{' '}
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
{version}
</span>
</>
)}
</p>
</footer>
);
}

View File

@@ -1,19 +1,26 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
interface AudioPlayerProps {
src: string;
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
startTime?: number; // Start offset in seconds (for curated specials)
onPlay?: () => void;
onReplay?: () => void;
autoPlay?: boolean;
onHasPlayedChange?: (hasPlayed: boolean) => void;
}
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, autoPlay = false }: AudioPlayerProps) {
export interface AudioPlayerRef {
play: () => void;
}
const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false, onHasPlayedChange }, ref) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
useEffect(() => {
if (audioRef.current) {
@@ -21,6 +28,8 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
audioRef.current.currentTime = startTime;
setIsPlaying(false);
setProgress(0);
setHasPlayedOnce(false); // Reset for new segment
onHasPlayedChange?.(false); // Notify parent
if (autoPlay) {
const playPromise = audioRef.current.play();
@@ -29,6 +38,8 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
.then(() => {
setIsPlaying(true);
onPlay?.();
setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
})
.catch(error => {
console.log("Autoplay prevented:", error);
@@ -39,6 +50,30 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
}
}, [src, unlockedSeconds, startTime, autoPlay]);
// Expose play method to parent component
useImperativeHandle(ref, () => ({
play: () => {
if (!audioRef.current) return;
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
if (!hasPlayedOnce) {
setHasPlayedOnce(true);
onHasPlayedChange?.(true);
}
})
.catch(error => {
console.error("Play failed:", error);
setIsPlaying(false);
});
}
}
}));
const togglePlay = () => {
if (!audioRef.current) return;
@@ -47,6 +82,13 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
} else {
audioRef.current.play();
onPlay?.();
if (hasPlayedOnce) {
onReplay?.();
} else {
setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
}
}
setIsPlaying(!isPlaying);
};
@@ -102,4 +144,8 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
</div>
</div>
);
}
});
AudioPlayer.displayName = 'AudioPlayer';
export default AudioPlayer;

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import AudioPlayer from './AudioPlayer';
import { useEffect, useState, useRef } from 'react';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput';
import Statistics from './Statistics';
import { useGameState } from '../lib/gameState';
@@ -16,6 +16,7 @@ interface GameProps {
title: string;
artist: string;
coverImage: string | null;
releaseYear?: number | null;
startTime?: number;
} | null;
genre?: string | null;
@@ -27,7 +28,7 @@ interface GameProps {
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('🔗 Share');
@@ -35,6 +36,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
const [timeUntilNext, setTimeUntilNext] = useState('');
const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false);
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
useEffect(() => {
const updateCountdown = () => {
@@ -50,7 +54,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000); // Update every second to be accurate
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, []);
@@ -58,6 +62,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState && dailyPuzzle) {
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) {
setShowYearModal(true);
}
}
}, [gameState, dailyPuzzle]);
@@ -87,37 +96,75 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (!gameState) return <div>Loading state...</div>;
const handleGuess = (song: any) => {
if (isProcessingGuess) return; // Prevent multiple guesses
if (isProcessingGuess) return;
setIsProcessingGuess(true);
setLastAction('GUESS');
if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true);
setHasWon(true);
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre);
// Notification sent after year guess or skip
if (!dailyPuzzle.releaseYear) {
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
}
} else {
addGuess(song.title, false);
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false); // Ensure won is false
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
}
// Reset after a short delay to allow UI update
setTimeout(() => setIsProcessingGuess(false), 500);
};
const handleStartAudio = () => {
// This will be called when user clicks "Start" button on first attempt
// Trigger the audio player to start playing
audioPlayerRef.current?.play();
setHasPlayedAudio(true);
};
const handleSkip = () => {
// If user hasn't played audio yet on first attempt, start it instead of skipping
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
handleStartAudio();
return;
}
setLastAction('SKIP');
addGuess("SKIPPED", false);
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
};
const handleGiveUp = () => {
setLastAction('SKIP');
addGuess("SKIPPED", false);
giveUp(); // Ensure game is marked as failed and score reset to 0
setHasLost(true);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
};
const handleYearGuess = (year: number) => {
const correct = year === dailyPuzzle.releaseYear;
addYearBonus(correct);
setShowYearModal(false);
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
};
const handleYearSkip = () => {
skipYearBonus();
setShowYearModal(false);
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
};
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
@@ -126,29 +173,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
let emojiGrid = '';
const totalGuesses = maxAttempts;
// Build the grid
for (let i = 0; i < totalGuesses; i++) {
if (i < gameState.guesses.length) {
// If this was the winning guess (last one and won)
if (hasWon && i === gameState.guesses.length - 1) {
emojiGrid += '🟩';
} else if (gameState.guesses[i] === 'SKIPPED') {
// Skipped
emojiGrid += '⬛';
} else {
// Wrong guess
emojiGrid += '🟥';
}
} else {
// Unused attempts
emojiGrid += '⬜';
}
}
const speaker = hasWon ? '🔉' : '🔇';
const genreText = genre ? `Genre: ${genre}\n` : '';
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
// Generate URL with genre/special path
let shareUrl = 'https://hoerdle.elpatron.me';
if (genre) {
if (isSpecial) {
@@ -158,9 +200,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
}
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\n${shareUrl}`;
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
// Try native Web Share API only on mobile devices
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile && navigator.share) {
@@ -173,14 +214,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setTimeout(() => setShareText('🔗 Share'), 2000);
return;
} catch (err) {
// User cancelled or error - fall through to clipboard
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
}
}
}
// Fallback: Copy to clipboard
try {
await navigator.clipboard.writeText(text);
setShareText('✓ Copied!');
@@ -192,8 +231,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
};
const handleRatingSubmit = async (rating: number) => {
if (!dailyPuzzle) return;
@@ -201,7 +238,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
setHasRated(true);
// Persist to localStorage
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
ratedPuzzles.push(dailyPuzzle.id);
@@ -222,17 +258,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span>
</div>
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
<AudioPlayer
ref={audioPlayerRef}
src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds}
startTime={dailyPuzzle.startTime}
autoPlay={lastAction === 'SKIP'}
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onReplay={addReplay}
onHasPlayedChange={setHasPlayedAudio}
/>
</div>
@@ -253,12 +294,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{!hasWon && !hasLost && (
<>
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
{gameState.guesses.length < 6 ? (
{gameState.guesses.length < maxAttempts - 1 ? (
<button
onClick={handleSkip}
className="skip-button"
>
Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
{gameState.guesses.length === 0 && !hasPlayedAudio
? 'Start'
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
}
</button>
) : (
<button
@@ -275,29 +319,49 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</>
)}
{hasWon && (
<div className="message-box success">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
<p>Come back tomorrow for a new song.</p>
{(hasWon || hasLost) && (
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{hasWon ? 'You won!' : 'Game Over'}
</h2>
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
Score: {gameState.score}
</div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
<summary>Score Breakdown</summary>
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
{gameState.scoreBreakdown.map((item, i) => (
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
<span>{item.reason}</span>
<span style={{ fontWeight: 'bold', color: item.value >= 0 ? 'green' : 'red' }}>
{item.value > 0 ? '+' : ''}{item.value}
</span>
</li>
))}
</ul>
</details>
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{dailyPuzzle.coverImage && (
<img
src={dailyPuzzle.coverImage}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
)}
<img
src={dailyPuzzle.coverImage || '/favicon.ico'}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
)}
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{/* Rating Component */}
<div style={{ marginBottom: '1rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
@@ -308,42 +372,150 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</button>
</div>
)}
{hasLost && (
<div className="message-box failure">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
<p>The song was:</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{dailyPuzzle.coverImage && (
<img
src={dailyPuzzle.coverImage}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
)}
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{/* Rating Component */}
<div style={{ marginBottom: '1rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
{statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main>
{showYearModal && dailyPuzzle.releaseYear && (
<YearGuessModal
correctYear={dailyPuzzle.releaseYear}
onGuess={handleYearGuess}
onSkip={handleYearSkip}
/>
)}
</div>
);
}
function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) {
const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n');
// Create expression: "90 - 2 - 5 + 10"
// Limit to last 5 items to avoid overflow if too long
const displayItems = breakdown.length > 5 ?
[{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] :
breakdown;
const expression = displayItems.map((item, index) => {
if (index === 0 && breakdown.length <= 5) return item.value.toString();
if (index === 0 && breakdown.length > 5) return `${item.value} ...`;
return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`;
}).join(' ');
return (
<div className="score-display" title={tooltipText} style={{
textAlign: 'center',
margin: '0.5rem 0',
padding: '0.5rem',
background: '#f3f4f6',
borderRadius: '0.5rem',
fontSize: '0.9rem',
fontFamily: 'monospace',
cursor: 'help'
}}>
<span style={{ color: '#666' }}>{expression} = </span>
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
</div>
);
}
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const [options, setOptions] = useState<number[]>([]);
useEffect(() => {
const currentYear = new Date().getFullYear();
const minYear = 1950;
const closeOptions = new Set<number>();
closeOptions.add(correctYear);
// Add 2 close years (+/- 2)
while (closeOptions.size < 3) {
const offset = Math.floor(Math.random() * 5) - 2;
const year = correctYear + offset;
if (year <= currentYear && year >= minYear && year !== correctYear) {
closeOptions.add(year);
}
}
const allOptions = new Set(closeOptions);
// Fill up to 10 with random years
while (allOptions.size < 10) {
const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear;
allOptions.add(year);
}
setOptions(Array.from(allOptions).sort((a, b) => a - b));
}, [correctYear]);
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem'
}}>
<div style={{
background: 'white',
padding: '2rem',
borderRadius: '1rem',
maxWidth: '500px',
width: '100%',
textAlign: 'center',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
}}>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem'
}}>
{options.map(year => (
<button
key={year}
onClick={() => onGuess(year)}
style={{
padding: '0.75rem',
background: '#f3f4f6',
border: '2px solid #e5e7eb',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
>
{year}
</button>
))}
</div>
<button
onClick={onSkip}
style={{
background: 'none',
border: 'none',
color: '#6b7280',
textDecoration: 'underline',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
Skip Bonus
</button>
</div>
</div>
);
}

199
components/NewsSection.tsx Normal file
View File

@@ -0,0 +1,199 @@
'use client';
import { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import Link from 'next/link';
interface NewsItem {
id: number;
title: string;
content: string;
author: string | null;
publishedAt: string;
featured: boolean;
special: {
id: number;
name: string;
} | null;
}
export default function NewsSection() {
const [news, setNews] = useState<NewsItem[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNews();
}, []);
const fetchNews = async () => {
try {
const res = await fetch('/api/news?limit=3');
if (res.ok) {
const data = await res.json();
setNews(data);
}
} catch (error) {
console.error('Failed to fetch news:', error);
} finally {
setLoading(false);
}
};
if (loading || news.length === 0) {
return null; // Don't show anything if no news
}
return (
<div style={{
background: '#f9fafb',
borderRadius: '0.5rem',
margin: '1rem auto',
maxWidth: '800px',
overflow: 'hidden',
border: '1px solid #e5e7eb'
}}>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
style={{
width: '100%',
padding: '0.75rem 1rem',
background: 'transparent',
border: 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: '600',
color: '#374151'
}}
>
<span>📰 News & Updates</span>
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
{isExpanded ? '▼' : '▶'}
</span>
</button>
{/* Content */}
{isExpanded && (
<div style={{
padding: '0 1rem 1rem 1rem',
borderTop: '1px solid #e5e7eb'
}}>
{news.map((item, index) => (
<div
key={item.id}
style={{
padding: '0.75rem 0',
borderBottom: index < news.length - 1 ? '1px solid #e5e7eb' : 'none'
}}
>
{/* Title */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.25rem'
}}>
{item.featured && (
<span style={{
background: '#fef3c7',
color: '#92400e',
padding: '0.125rem 0.375rem',
borderRadius: '0.25rem',
fontSize: '0.625rem',
fontWeight: '600'
}}>
FEATURED
</span>
)}
<h3 style={{
margin: 0,
fontSize: '0.875rem',
fontWeight: '600',
color: '#111827'
}}>
{item.title}
</h3>
</div>
{/* Metadata */}
<div style={{
fontSize: '0.75rem',
color: '#6b7280',
marginBottom: '0.5rem',
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap'
}}>
<span>
{new Date(item.publishedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</span>
{item.author && (
<>
<span></span>
<span>by {item.author}</span>
</>
)}
{item.special && (
<>
<span></span>
<Link
href={`/special/${item.special.name}`}
style={{
color: '#be185d',
textDecoration: 'none',
fontWeight: '500'
}}
>
{item.special.name}
</Link>
</>
)}
</div>
{/* Content */}
<div
className="news-content"
style={{
fontSize: '0.875rem',
color: '#374151',
lineHeight: '1.5'
}}
>
<ReactMarkdown
components={{
p: ({ children }) => <p style={{ margin: '0.5rem 0' }}>{children}</p>,
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#4f46e5', textDecoration: 'underline' }}
>
{children}
</a>
),
strong: ({ children }) => <strong style={{ fontWeight: '600' }}>{children}</strong>,
em: ({ children }) => <em style={{ fontStyle: 'italic' }}>{children}</em>,
ul: ({ children }) => <ul style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ol>,
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
}}
>
{item.content}
</ReactMarkdown>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -24,6 +24,6 @@ services:
timeout: 10s
retries: 3
start_period: 40s
# Initialize DB if needed and run migration
# Run migrations and start server (auto-baseline on first run if needed)
command: >
sh -c "npx -y prisma@6.19.0 db push && node scripts/migrate-covers.mjs && node server.js"
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"

37
lib/auth.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Authentication middleware for admin API routes
* Verifies that the request includes a valid admin session token
*/
export async function requireAdminAuth(request: NextRequest): Promise<NextResponse | null> {
const authHeader = request.headers.get('x-admin-auth');
if (!authHeader || authHeader !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized - Admin authentication required' },
{ status: 401 }
);
}
return null; // Auth successful
}
/**
* Helper to verify admin password
*/
export async function verifyAdminPassword(password: string): Promise<boolean> {
const bcrypt = await import('bcryptjs');
// Validate that ADMIN_PASSWORD is set (security best practice)
if (!process.env.ADMIN_PASSWORD) {
console.error('SECURITY WARNING: ADMIN_PASSWORD environment variable is not set!');
// Fallback to default hash only in development
if (process.env.NODE_ENV === 'production') {
throw new Error('ADMIN_PASSWORD environment variable is required in production');
}
}
const adminPasswordHash = process.env.ADMIN_PASSWORD || '$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq';
return bcrypt.compare(password, adminPasswordHash);
}

View File

@@ -27,13 +27,13 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
include: { song: true },
});
console.log(`[Daily Puzzle] Date: ${today}, Genre: ${genreName || 'Global'}, Found existing: ${!!dailyPuzzle}`);
if (!dailyPuzzle) {
// Get songs available for this genre
const whereClause = genreId
? { genres: { some: { id: genreId } } }
: {}; // Global puzzle picks from ALL songs
: { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
const allSongs = await prisma.song.findMany({
where: whereClause,
@@ -118,6 +118,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
genre: genreName
};
@@ -230,6 +231,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
special: specialName,
maxAttempts: special.maxAttempts,
unlockSteps: JSON.parse(special.unlockSteps),

97
lib/fuzzyMatch.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Fuzzy string matching utility for duplicate detection
* Uses Levenshtein distance to compare strings with tolerance for formatting variations
*/
/**
* Normalize a string for comparison
* - Converts to lowercase
* - Removes special characters
* - Normalizes whitespace
*/
function normalizeString(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '') // Remove special chars
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Calculate Levenshtein distance between two strings
* Returns the minimum number of single-character edits needed to change one string into the other
*/
function levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
// Initialize first column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
// Initialize first row
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the rest of the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
/**
* Check if two strings are similar based on Levenshtein distance
* @param str1 First string to compare
* @param str2 Second string to compare
* @param threshold Similarity threshold (0-1), default 0.85
* @returns true if strings are similar enough
*/
export function isSimilar(str1: string, str2: string, threshold = 0.85): boolean {
if (!str1 || !str2) return false;
const norm1 = normalizeString(str1);
const norm2 = normalizeString(str2);
// Exact match after normalization
if (norm1 === norm2) return true;
const distance = levenshteinDistance(norm1, norm2);
const maxLen = Math.max(norm1.length, norm2.length);
// Avoid division by zero
if (maxLen === 0) return true;
const similarity = 1 - (distance / maxLen);
return similarity >= threshold;
}
/**
* Check if a song (artist + title) is a duplicate of another
* Both artist AND title must be similar for a match
*/
export function isDuplicateSong(
artist1: string,
title1: string,
artist2: string,
title2: string,
threshold = 0.85
): boolean {
return isSimilar(artist1, artist2, threshold) && isSimilar(title1, title2, threshold);
}

View File

@@ -9,6 +9,11 @@ export interface GameState {
isSolved: boolean;
isFailed: boolean;
lastPlayed: number; // Timestamp
score: number;
replayCount: number;
skipCount: number;
scoreBreakdown: Array<{ value: number; reason: string }>;
yearGuessed: boolean;
}
export interface Statistics {
@@ -22,19 +27,31 @@ export interface Statistics {
failed: number;
}
const STORAGE_KEY = 'hoerdle_game_state';
const STATS_KEY = 'hoerdle_statistics';
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) {
const [gameState, setGameState] = useState<GameState | null>(null);
const [statistics, setStatistics] = useState<Statistics | null>(null);
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY_PREFIX = 'hoerdle_statistics';
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
const createNewState = (date: string): GameState => ({
date,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
score: INITIAL_SCORE,
replayCount: 0,
skipCount: 0,
scoreBreakdown: [{ value: INITIAL_SCORE, reason: 'Start value' }],
yearGuessed: false
});
useEffect(() => {
// Load game state
const storageKey = getStorageKey();
@@ -42,30 +59,29 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
const today = getTodayISOString();
if (stored) {
const parsed: GameState = JSON.parse(stored);
const parsed = JSON.parse(stored);
if (parsed.date === today) {
setGameState(parsed);
// Migration for existing states without score
if (parsed.score === undefined) {
parsed.score = INITIAL_SCORE;
parsed.replayCount = 0;
parsed.skipCount = 0;
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
parsed.yearGuessed = false;
// Retroactively deduct points for existing guesses if possible,
// but simpler to just start at 90 for active games to avoid confusion
}
setGameState(parsed as GameState);
} else {
// New day
const newState: GameState = {
date: today,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
};
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
} else {
// No state
const newState: GameState = {
date: today,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
};
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
@@ -116,8 +132,6 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
case 6: newStats.solvedIn6++; break;
case 7: newStats.solvedIn7++; break;
default:
// For custom attempts > 7, we currently don't have specific stats buckets
// We could add a 'solvedInOther' or just ignore for now
break;
}
} else {
@@ -135,12 +149,43 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
const isSolved = correct;
const isFailed = !correct && newGuesses.length >= maxAttempts;
let newScore = gameState.score;
const newBreakdown = [...gameState.scoreBreakdown];
if (correct) {
newScore += 20;
newBreakdown.push({ value: 20, reason: 'Correct Answer' });
} else {
if (guess === 'SKIPPED') {
newScore -= 5;
newBreakdown.push({ value: -5, reason: 'Skip' });
} else {
newScore -= 3;
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
}
}
// If failed, reset score to 0
if (isFailed) {
if (newScore > 0) {
newBreakdown.push({ value: -newScore, reason: 'Game Over' });
newScore = 0;
}
}
// Ensure score doesn't go below 0
newScore = Math.max(0, newScore);
const newState = {
...gameState,
guesses: newGuesses,
isSolved,
isFailed,
lastPlayed: Date.now(),
score: newScore,
scoreBreakdown: newBreakdown,
// Update skip count if skipped
skipCount: guess === 'SKIPPED' ? gameState.skipCount + 1 : gameState.skipCount
};
saveState(newState);
@@ -151,5 +196,79 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
}
};
return { gameState, statistics, addGuess };
const giveUp = () => {
if (!gameState || gameState.isSolved || gameState.isFailed) return;
let newScore = 0;
const newBreakdown = [...gameState.scoreBreakdown];
if (gameState.score > 0) {
newBreakdown.push({ value: -gameState.score, reason: 'Gave Up' });
}
const newState = {
...gameState,
isFailed: true,
score: 0,
scoreBreakdown: newBreakdown,
lastPlayed: Date.now()
};
saveState(newState);
updateStatistics(gameState.guesses.length, false);
};
const addReplay = () => {
if (!gameState || gameState.isSolved || gameState.isFailed) return;
let newScore = gameState.score - 1;
// Ensure score doesn't go below 0
newScore = Math.max(0, newScore);
const newBreakdown = [...gameState.scoreBreakdown, { value: -1, reason: 'Replay snippet' }];
const newState = {
...gameState,
replayCount: gameState.replayCount + 1,
score: newScore,
scoreBreakdown: newBreakdown
};
saveState(newState);
};
const addYearBonus = (correct: boolean) => {
if (!gameState) return;
let newScore = gameState.score;
const newBreakdown = [...gameState.scoreBreakdown];
if (correct) {
newScore += 10;
newBreakdown.push({ value: 10, reason: 'Bonus: Correct Year' });
} else {
newBreakdown.push({ value: 0, reason: 'Bonus: Wrong Year' });
}
const newState = {
...gameState,
score: newScore,
scoreBreakdown: newBreakdown,
yearGuessed: true
};
saveState(newState);
};
const skipYearBonus = () => {
if (!gameState) return;
const newBreakdown = [...gameState.scoreBreakdown, { value: 0, reason: 'Bonus: Skipped' }];
const newState = {
...gameState,
scoreBreakdown: newBreakdown,
yearGuessed: true
};
saveState(newState);
};
return { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus };
}

125
lib/itunes.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* iTunes Search API integration for fetching release years
* API Documentation: https://performance-partners.apple.com/search-api
*/
interface ItunesResult {
wrapperType: string;
kind: string;
artistName: string;
collectionName: string;
trackName: string;
releaseDate: string;
primaryGenreName: string;
}
interface ItunesResponse {
resultCount: number;
results: ItunesResult[];
}
// Rate limiting state
let lastRequestTime = 0;
let blockedUntil = 0;
const MIN_INTERVAL = 2000; // 2 seconds = 30 requests per minute
const BLOCK_DURATION = 60000; // 60 seconds pause after 403
// Mutex for serializing requests
let requestQueue = Promise.resolve<any>(null);
/**
* Get the earliest release year for a song from iTunes
* @param artist Artist name
* @param title Song title
* @returns Release year or null if not found
*/
export async function getReleaseYearFromItunes(artist: string, title: string): Promise<number | null> {
// Queue the request to ensure sequential execution and rate limiting
const result = requestQueue.then(() => executeRequest(artist, title));
// Update queue to wait for this request
requestQueue = result.catch(() => null);
return result;
}
async function executeRequest(artist: string, title: string): Promise<number | null> {
try {
// Check if blocked
const now = Date.now();
if (now < blockedUntil) {
const waitTime = blockedUntil - now;
console.log(`iTunes API blocked (403/429). Waiting ${Math.ceil(waitTime / 1000)}s before next request...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
// Enforce rate limit (min interval)
const timeSinceLast = Date.now() - lastRequestTime;
if (timeSinceLast < MIN_INTERVAL) {
const delay = MIN_INTERVAL - timeSinceLast;
await new Promise(resolve => setTimeout(resolve, delay));
}
// Construct search URL
const term = encodeURIComponent(`${artist} ${title}`);
const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=10`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
}
});
lastRequestTime = Date.now();
if (response.status === 403 || response.status === 429) {
console.warn(`iTunes API rate limit hit (${response.status}). Pausing for 60s.`);
blockedUntil = Date.now() + BLOCK_DURATION;
return null;
}
if (!response.ok) {
console.error(`iTunes API error: ${response.status} ${response.statusText}`);
return null;
}
const data: ItunesResponse = await response.json();
if (data.resultCount === 0) {
return null;
}
// Filter for exact(ish) matches to avoid wrong songs
// and find the earliest release date
let earliestYear: number | null = null;
const normalizedTitle = title.toLowerCase().replace(/[^\w\s]/g, '');
const normalizedArtist = artist.toLowerCase().replace(/[^\w\s]/g, '');
for (const result of data.results) {
// Basic validation that it's the right song
const resTitle = result.trackName.toLowerCase().replace(/[^\w\s]/g, '');
const resArtist = result.artistName.toLowerCase().replace(/[^\w\s]/g, '');
// Check if title and artist are contained in the result (fuzzy match)
if (resTitle.includes(normalizedTitle) && resArtist.includes(normalizedArtist)) {
if (result.releaseDate) {
const year = new Date(result.releaseDate).getFullYear();
if (!isNaN(year)) {
if (earliestYear === null || year < earliestYear) {
earliestYear = year;
}
}
}
}
}
return earliestYear;
} catch (error) {
console.error(`Error fetching release year from iTunes for "${title}" by "${artist}":`, error);
return null;
}
}

76
lib/rateLimit.ts Normal file
View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Rate limiting configuration
* Simple in-memory rate limiter for API endpoints
*/
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitMap = new Map<string, RateLimitEntry>();
// Clean up old entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitMap.entries()) {
if (now > entry.resetTime) {
rateLimitMap.delete(key);
}
}
}, 5 * 60 * 1000);
export interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Maximum requests per window
}
/**
* Rate limiting middleware
* @param request - The incoming request
* @param config - Rate limit configuration
* @returns NextResponse with 429 status if rate limit exceeded, null otherwise
*/
export function rateLimit(
request: NextRequest,
config: RateLimitConfig = { windowMs: 60000, maxRequests: 100 }
): NextResponse | null {
// Get client identifier (IP address or fallback)
const identifier =
request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.headers.get('x-real-ip') ||
'unknown';
const now = Date.now();
const entry = rateLimitMap.get(identifier);
if (!entry || now > entry.resetTime) {
// Create new entry or reset expired entry
rateLimitMap.set(identifier, {
count: 1,
resetTime: now + config.windowMs
});
return null;
}
if (entry.count >= config.maxRequests) {
const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': retryAfter.toString(),
'X-RateLimit-Limit': config.maxRequests.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': new Date(entry.resetTime).toISOString()
}
}
);
}
// Increment counter
entry.count++;
return null;
}

52
middleware.ts Normal file
View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Security Headers
const headers = response.headers;
// Prevent clickjacking
headers.set('X-Frame-Options', 'SAMEORIGIN');
// XSS Protection (legacy but still useful)
headers.set('X-XSS-Protection', '1; mode=block');
// Prevent MIME type sniffing
headers.set('X-Content-Type-Options', 'nosniff');
// Referrer Policy
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy (restrict features)
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Content Security Policy
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval
"style-src 'self' 'unsafe-inline'", // Allow inline styles
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https://openrouter.ai https://gotify.example.com",
"media-src 'self' blob:",
"frame-ancestors 'self'",
].join('; ');
headers.set('Content-Security-Policy', csp);
return response;
}
// Apply middleware to all routes
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};

View File

@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
serverActions: {
bodySizeLimit: '50mb',
},
middlewareClientMaxBodySize: '50mb',
},
env: {
TZ: process.env.TZ || 'Europe/Berlin',

1180
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,8 @@
"next": "16.0.3",
"prisma": "^6.19.0",
"react": "19.2.0",
"react-dom": "19.2.0"
"react-dom": "19.2.0",
"react-markdown": "^10.1.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",

BIN
prisma/dev.db.bak Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Genre" ADD COLUMN "subtitle" TEXT;
-- AlterTable
ALTER TABLE "Special" ADD COLUMN "subtitle" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Song" ADD COLUMN "releaseYear" INTEGER;

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_DailyPuzzle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL,
"songId" INTEGER NOT NULL,
"genreId" INTEGER,
"specialId" INTEGER,
CONSTRAINT "DailyPuzzle_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_DailyPuzzle" ("date", "genreId", "id", "songId", "specialId") SELECT "date", "genreId", "id", "songId", "specialId" FROM "DailyPuzzle";
DROP TABLE "DailyPuzzle";
ALTER TABLE "new_DailyPuzzle" RENAME TO "DailyPuzzle";
CREATE UNIQUE INDEX "DailyPuzzle_date_genreId_specialId_key" ON "DailyPuzzle"("date", "genreId", "specialId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Song" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"coverImage" TEXT,
"releaseYear" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"averageRating" REAL NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"excludeFromGlobal" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Song" ("artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title") SELECT "artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title" FROM "Song";
DROP TABLE "Song";
ALTER TABLE "new_Song" RENAME TO "Song";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,15 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Genre" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"subtitle" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO "new_Genre" ("id", "name", "subtitle") SELECT "id", "name", "subtitle" FROM "Genre";
DROP TABLE "Genre";
ALTER TABLE "new_Genre" RENAME TO "Genre";
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "News" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"author" TEXT,
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"featured" BOOLEAN NOT NULL DEFAULT false,
"specialId" INTEGER,
CONSTRAINT "News_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "News_publishedAt_idx" ON "News"("publishedAt");

View File

@@ -16,17 +16,21 @@ model Song {
artist String
filename String // Filename in public/uploads
coverImage String? // Filename in public/uploads/covers
releaseYear Int? // Release year from iTunes
createdAt DateTime @default(now())
puzzles DailyPuzzle[]
genres Genre[]
specials SpecialSong[]
averageRating Float @default(0)
ratingCount Int @default(0)
excludeFromGlobal Boolean @default(false)
}
model Genre {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
active Boolean @default(true)
songs Song[]
dailyPuzzles DailyPuzzle[]
}
@@ -34,6 +38,7 @@ model Genre {
model Special {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
maxAttempts Int @default(7)
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
createdAt DateTime @default(now())
@@ -42,6 +47,7 @@ model Special {
curator String?
songs SpecialSong[]
puzzles DailyPuzzle[]
news News[]
}
model SpecialSong {
@@ -60,7 +66,7 @@ model DailyPuzzle {
id Int @id @default(autoincrement())
date String // Format: YYYY-MM-DD
songId Int
song Song @relation(fields: [songId], references: [id])
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id])
specialId Int?
@@ -68,3 +74,17 @@ model DailyPuzzle {
@@unique([date, genreId, specialId])
}
model News {
id Int @id @default(autoincrement())
title String
content String // Markdown format
author String? // Optional: curator/admin name
publishedAt DateTime @default(now())
updatedAt DateTime @updatedAt
featured Boolean @default(false) // Highlight important news
specialId Int? // Optional: link to a special
special Special? @relation(fields: [specialId], references: [id], onDelete: SetNull)
@@index([publishedAt])
}

18
scripts/baseline-migrations.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# One-time script to baseline existing production database with migration history
# Run this ONCE on production server: docker exec hoerdle sh scripts/baseline-migrations.sh
echo "🔧 Baselining migration history for existing database..."
# Mark all existing migrations as applied
npx prisma migrate resolve --applied "20251122121934_add_specials"
npx prisma migrate resolve --applied "20251122140952_add_specials_real"
npx prisma migrate resolve --applied "20251123012306_add_special_curator"
npx prisma migrate resolve --applied "20251123012308_add_special_song_model"
npx prisma migrate resolve --applied "20251123020226_add_special_scheduling"
npx prisma migrate resolve --applied "20251123083856_add_rating_system"
npx prisma migrate resolve --applied "20251123140527_add_subtitles"
npx prisma migrate resolve --applied "20251123181922_add_release_year"
npx prisma migrate resolve --applied "20251123204000_fix_cascade_delete"
echo "✅ Baseline complete! Restart the container to apply migrations normally."

View File

@@ -3,10 +3,57 @@ set -e
echo "🚀 Starting optimized deployment..."
# Backup database
echo "💾 Creating database backup..."
# Try to find database path from docker-compose.yml or .env
DB_PATH=""
# Check if docker-compose.yml exists and extract DATABASE_URL
if [ -f "docker-compose.yml" ]; then
DB_PATH=$(grep -oP 'DATABASE_URL=file:\K[^\s]+' docker-compose.yml | head -1)
fi
# Fallback to .env if not found
if [ -z "$DB_PATH" ] && [ -f ".env" ]; then
DB_PATH=$(grep -oP '^DATABASE_URL=file:\K.+' .env | head -1)
fi
# Remove any quotes and resolve path
DB_PATH=$(echo "$DB_PATH" | tr -d '"' | tr -d "'")
if [ -n "$DB_PATH" ]; then
# Convert container path to host path if needed
# /app/data/prod.db -> ./data/prod.db
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
if [ -f "$DB_PATH" ]; then
# Create backups directory
mkdir -p ./backups
# Create timestamped backup
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_$(date +%Y%m%d_%H%M%S).db"
cp "$DB_PATH" "$BACKUP_FILE"
echo "✅ Database backed up to: $BACKUP_FILE"
# Keep only last 10 backups
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
echo "🧹 Cleaned old backups (keeping last 10)"
else
echo "⚠️ Database file not found at: $DB_PATH"
fi
else
echo "⚠️ Could not determine database path from config files"
fi
# Pull latest changes
echo "📥 Pulling latest changes from git..."
git pull
# Fetch all tags
echo "🏷️ Fetching git tags..."
git fetch --tags
# Build new image in background (doesn't stop running container)
echo "🔨 Building new Docker image (this runs while app is still online)..."
docker compose build

View File

@@ -1,12 +1,20 @@
#!/bin/sh
set -e
# Export version if available
if [ -f /app/version.txt ]; then
export APP_VERSION=$(cat /app/version.txt)
echo "App version: $APP_VERSION"
fi
echo "Starting deployment..."
# Run migrations
echo "Running database migrations..."
npx prisma migrate deploy
# Start the application
echo "Starting application..."
exec node server.js

View File

@@ -1,6 +1,6 @@
import { PrismaClient } from '@prisma/client';
import { parseBuffer } from 'music-metadata';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { readFile, writeFile, mkdir, access } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -11,6 +11,16 @@ const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
async function migrate() {
// Check if migration already ran
const flagPath = path.join(process.cwd(), '.covers-migrated');
try {
await access(flagPath);
console.log('✅ Cover migration already completed (flag file exists). Skipping...');
return;
} catch {
// Flag file doesn't exist, proceed with migration
}
console.log('Starting cover art migration...');
try {
@@ -23,10 +33,18 @@ async function migrate() {
console.log(`Found ${songs.length} songs without cover image.`);
if (songs.length === 0) {
console.log('✅ All songs already have cover images!');
await writeFile(flagPath, new Date().toISOString());
return;
}
let processed = 0;
let successful = 0;
for (const song of songs) {
try {
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
console.log(`Processing ${song.title} (${song.filename})...`);
const buffer = await readFile(filePath);
const metadata = await parseBuffer(buffer);
@@ -47,16 +65,20 @@ async function migrate() {
data: { coverImage: coverFilename }
});
console.log(`✅ Extracted cover for ${song.title}`);
} else {
console.log(`⚠️ No cover found for ${song.title}`);
successful++;
}
processed++;
} catch (e) {
console.error(`❌ Failed to process ${song.title}:`, e.message);
processed++;
}
}
console.log('Migration completed.');
console.log(`✅ Cover migration completed: ${successful}/${processed} songs processed successfully.`);
// Create flag file to prevent re-running
await writeFile(flagPath, new Date().toISOString());
console.log(`🏁 Created flag file: ${flagPath}`);
} catch (e) {
console.error('Migration failed:', e);
} finally {

View File

@@ -0,0 +1,211 @@
/**
* Robust iTunes Refresh Script
*
* Usage:
* ADMIN_PASSWORD='your_password' node scripts/slow-refresh-itunes.js
*
* Options:
* --force Overwrite existing release years
*/
const API_URL = process.env.API_URL || 'http://localhost:3010';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
if (!ADMIN_PASSWORD) {
console.error('❌ Error: ADMIN_PASSWORD environment variable is required.');
process.exit(1);
}
const FORCE_UPDATE = process.argv.includes('--force');
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36';
// Helper for delays
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Helper to clean search terms
function cleanSearchTerm(text) {
return text
.replace(/_Unplugged/gi, '')
.replace(/_Remastered/gi, '')
.replace(/_Live/gi, '')
.replace(/_Acoustic/gi, '')
.replace(/_Radio Edit/gi, '')
.replace(/_Extended/gi, '')
.replace(/_/g, ' ')
.trim();
}
async function main() {
console.log(`🎵 Starting iTunes Refresh Script`);
console.log(` Target: ${API_URL}`);
console.log(` Force Update: ${FORCE_UPDATE}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
try {
// 1. Authenticate
console.log('🔑 Authenticating...');
const loginRes = await fetch(`${API_URL}/api/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: ADMIN_PASSWORD })
});
if (!loginRes.ok) {
throw new Error(`Login failed: ${loginRes.status} ${loginRes.statusText}`);
}
// We need to manually manage the cookie/header if the API uses cookies,
// but the Admin UI uses a custom header 'x-admin-auth'.
// Let's verify if the login endpoint returns a token or if we just use the password/flag.
// Looking at the code, the client sets 'x-admin-auth' to 'authenticated' in localStorage.
// The API middleware likely checks a cookie or just this header?
// Let's check lib/auth.ts... actually, let's just assume we need to send the header.
// Wait, the frontend sets 'x-admin-auth' to 'authenticated' after successful login.
// The middleware likely checks the session cookie set by the login route.
// Let's get the cookie from the login response
const cookie = loginRes.headers.get('set-cookie');
const headers = {
'Content-Type': 'application/json',
'Cookie': cookie || '',
'x-admin-auth': 'authenticated' // Just in case
};
// 2. Fetch Songs
console.log('📥 Fetching song list...');
const songsRes = await fetch(`${API_URL}/api/songs`, { headers });
if (!songsRes.ok) throw new Error(`Failed to fetch songs: ${songsRes.status}`);
const songs = await songsRes.json();
console.log(`📊 Found ${songs.length} songs.`);
let processed = 0;
let updated = 0;
let skipped = 0;
let failed = 0;
for (const song of songs) {
processed++;
const progress = `[${processed}/${songs.length}]`;
// Skip if year exists and not forcing
if (song.releaseYear && !FORCE_UPDATE) {
// console.log(`${progress} Skipping "${song.title}" (Year: ${song.releaseYear})`);
skipped++;
continue;
}
console.log(`${progress} Processing: "${song.title}" by "${song.artist}"`);
const cleanArtist = cleanSearchTerm(song.artist);
const cleanTitle = cleanSearchTerm(song.title);
console.log(` → Searching: "${cleanTitle}" by "${cleanArtist}"`);
// 3. Query iTunes with Retry Logic
let year = null;
let retries = 0;
const MAX_RETRIES = 3;
while (retries < MAX_RETRIES) {
try {
const term = encodeURIComponent(`${cleanArtist} ${cleanTitle}`);
const itunesUrl = `https://itunes.apple.com/search?term=${term}&entity=song&limit=5`;
const res = await fetch(itunesUrl, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
}
});
if (res.status === 403 || res.status === 429) {
console.warn(` ⚠️ iTunes Rate Limit (${res.status}). Pausing for 60s...`);
await sleep(60000);
retries++;
continue;
}
if (!res.ok) {
console.error(` ❌ iTunes Error: ${res.status}`);
break;
}
const data = await res.json();
if (data.resultCount > 0) {
// Simple extraction logic (same as lib/itunes.ts)
let earliestYear = null;
const normalizedTitle = song.title.toLowerCase().replace(/[^\w\s]/g, '');
const normalizedArtist = song.artist.toLowerCase().replace(/[^\w\s]/g, '');
for (const result of data.results) {
const resTitle = result.trackName.toLowerCase().replace(/[^\w\s]/g, '');
const resArtist = result.artistName.toLowerCase().replace(/[^\w\s]/g, '');
if (resTitle.includes(normalizedTitle) && resArtist.includes(normalizedArtist)) {
if (result.releaseDate) {
const y = new Date(result.releaseDate).getFullYear();
if (!isNaN(y) && (earliestYear === null || y < earliestYear)) {
earliestYear = y;
}
}
}
}
year = earliestYear;
}
break; // Success
} catch (e) {
console.error(` ❌ Network Error: ${e.message}`);
retries++;
await sleep(5000);
}
}
if (year) {
if (year !== song.releaseYear) {
console.log(` ✅ Found Year: ${year} (Old: ${song.releaseYear})`);
// 4. Update Song
const updateRes = await fetch(`${API_URL}/api/songs`, {
method: 'PUT',
headers,
body: JSON.stringify({
id: song.id,
title: song.title,
artist: song.artist,
releaseYear: year
})
});
if (updateRes.ok) {
updated++;
} else {
console.error(` ❌ Failed to update API: ${updateRes.status}`);
failed++;
}
} else {
console.log(` Create (No Change): ${year}`);
skipped++;
}
} else {
console.log(` ⚠️ No year found.`);
failed++;
}
// Rate Limit Delay (15s = 4 req/min)
await sleep(15000);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ Done!');
console.log(`Updated: ${updated} | Skipped: ${skipped} | Failed: ${failed}`);
} catch (error) {
console.error('❌ Fatal Error:', error);
}
}
main();