Compare commits

...

107 Commits

Author SHA1 Message Date
Hördle Bot
50511f11ac chore: bump version to 0.1.0.14 2025-11-27 11:20:15 +01:00
Hördle Bot
d69ac28bb3 feat: white label transformation and bugfix for audio stream 2025-11-27 11:19:32 +01:00
Hördle Bot
7a65c58214 feat: add healthcheck endpoint and bump version to 0.1.0.13 2025-11-26 23:39:06 +01:00
Hördle Bot
1a8177430d feat: add health check API endpoint 2025-11-26 23:37:40 +01:00
Hördle Bot
0ebb61515d docs: Add 'prototype' to footer disclaimer in AppFooter. 2025-11-26 20:42:55 +01:00
Hördle Bot
dede11d22b fix: correct plausible score calculation 2025-11-26 18:00:59 +01:00
Hördle Bot
4b96b95bff Feat: Add Plausible event tracking for puzzle completion 2025-11-26 11:29:30 +01:00
Hördle Bot
89fb296564 Feat: Add visual feedback to bonus year question 2025-11-26 11:06:34 +01:00
Hördle Bot
301dce4c97 Fix: Audio player skip behavior and range requests 2025-11-26 10:58:04 +01:00
Hördle Bot
b66bab48bd Feat: Add Onboarding Assistant with driver.js 2025-11-26 10:13:40 +01:00
Hördle Bot
fea8384e60 fix: Adjust vertical spacing for next puzzle timer. 2025-11-26 09:27:20 +01:00
Hördle Bot
de8813da3e feat: filter genres by active status when fetching from Prisma 2025-11-26 09:25:45 +01:00
Hördle Bot
0877842107 feat: add plausible.elpatron.me to CSP script-src and connect-src directives. 2025-11-25 22:34:32 +01:00
Hördle Bot
a5cbbffc20 chore: remove Content-Security-Policy header configuration 2025-11-25 22:31:13 +01:00
Hördle Bot
ffb7be602f feat: Add Content Security Policy header and move Plausible script to HTML head with beforeInteractive strategy. 2025-11-25 22:19:34 +01:00
Hördle Bot
1d62aca2fb feat: add Plausible Analytics script to layout for tracking. 2025-11-25 22:13:46 +01:00
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
Hördle Bot
f13a719d0e fix: improve rating UI label and notification context 2025-11-23 10:42:20 +01:00
Hördle Bot
4d3032df36 feat: implement song rating system with admin view and user persistence 2025-11-23 10:34:45 +01:00
Hördle Bot
dc83c8372f feat: update share result emojis to distinguish failed (red) vs skipped (black) attempts 2025-11-23 08:36:39 +01:00
Hördle Bot
7795168b16 docs: update readme with special scheduling and curator features 2025-11-23 02:36:26 +01:00
Hördle Bot
8262a96213 feat(home): format upcoming special dates with TZ support 2025-11-23 02:33:22 +01:00
Hördle Bot
b294a3a8e6 feat(home): display curator in upcoming specials 2025-11-23 02:29:23 +01:00
Hördle Bot
2a38bce02c feat(specials): add curator field 2025-11-23 02:25:45 +01:00
Hördle Bot
6fd5f8ed0c style(admin): fix button height consistency 2025-11-23 02:16:42 +01:00
Hördle Bot
c05ead4493 fix(api): include song count in specials list 2025-11-23 02:15:12 +01:00
Hördle Bot
5fb450d37e style(admin): unify button styles for edit, delete, and cancel 2025-11-23 02:13:11 +01:00
Hördle Bot
e9526918e1 feat(specials): add launch and end dates for scheduling 2025-11-23 02:09:49 +01:00
Hördle Bot
69eb69b8cd docs: add Special Curation feature documentation 2025-11-23 01:52:28 +01:00
Hördle Bot
4bb0aed323 feat: Special Curation feature with waveform editor
- Database: SpecialSong model with startTime field
- Backend: API endpoints for Special curation
- Admin: Waveform editor with segment markers, zoom, and playback
- Game: startTime support in AudioPlayer
- UI: Manual save button, hover preview, playback cursor
- Migration: Automatic migration on deployment
2025-11-23 01:24:52 +01:00
Hördle Bot
fb911ccf4c feat(migration): add migration for SpecialSong model 2025-11-23 01:23:49 +01:00
Hördle Bot
ec885212a5 feat(waveform): add live hover preview for selection positioning 2025-11-23 01:18:59 +01:00
Hördle Bot
54f47a9470 fix(waveform): fix playback cursor animation using useEffect 2025-11-23 01:10:04 +01:00
Hördle Bot
e06e0d2919 feat(special-editor): add manual save button with unsaved changes indicator 2025-11-23 01:07:53 +01:00
Hördle Bot
23c2697424 feat(waveform): add playback cursor showing current position 2025-11-23 01:06:19 +01:00
Hördle Bot
86829af17d feat(waveform): add segment markers, zoom, and individual segment playback 2025-11-23 01:03:03 +01:00
Hördle Bot
b27d5e49c9 fix(api): update songs endpoint to work with SpecialSong model 2025-11-23 00:57:02 +01:00
Hördle Bot
5944c14614 fix(api): await params in Next.js 15 dynamic routes 2025-11-23 00:53:36 +01:00
Hördle Bot
587fa59b79 feat(special-curation): complete implementation with all components
- Database: SpecialSong model with startTime
- Backend: API endpoints for curation
- Admin: Waveform editor and curation page
- Game: startTime support in AudioPlayer
- UI: Curate button in admin dashboard
2025-11-23 00:50:35 +01:00
Hördle Bot
4f088305df fix(admin): add Curate button linking to Special curation page 2025-11-23 00:48:57 +01:00
Hördle Bot
e24588f3ee feat(pwa): add one-time install prompt for mobile devices 2025-11-23 00:22:23 +01:00
Hördle Bot
4b9e7ac9ec fix(logic): use sequential puzzle number instead of database ID 2025-11-23 00:12:04 +01:00
Hördle Bot
77a769fb91 fix(admin): filter duplicate daily puzzles in dashboard 2025-11-23 00:01:06 +01:00
Hördle Bot
291fc2037c feat(ui): add countdown timer to next daily puzzle 2025-11-22 23:52:13 +01:00
Hördle Bot
fdc5594d69 docs: clarify docker-compose dollar sign escaping for bcrypt hashes 2025-11-22 22:55:27 +01:00
Hördle Bot
aff752d4cb feat(security): implement bcrypt hashing for admin password and cleanup Dockerfile 2025-11-22 22:37:46 +01:00
Hördle Bot
23e145e05f fix(admin): update genre counts after song modifications 2025-11-22 22:29:32 +01:00
Hördle Bot
a6cfa833e3 refactor(admin): remove separate Artist column from Song Library 2025-11-22 22:27:10 +01:00
Hördle Bot
cf43adf63b Restrict native share dialog to mobile devices, use clipboard copy on desktop 2025-11-22 20:17:15 +01:00
Hördle Bot
58e9b4fa60 Improve share button: mobile-friendly Web Share API, icon button, fallback clipboard 2025-11-22 19:39:32 +01:00
Hördle Bot
328c8fe98a Prevent multiple guess submissions per attempt 2025-11-22 19:35:12 +01:00
Hördle Bot
5d5a75a735 Add genre/special-specific URLs to share functionality 2025-11-22 19:30:49 +01:00
Hördle Bot
7fc1c2c201 Fix audio playback for newly uploaded files by using dynamic API route 2025-11-22 17:53:37 +01:00
Hördle Bot
6086e1903c Fix audio file caching to allow immediate playback of newly uploaded songs 2025-11-22 17:42:20 +01:00
Hördle Bot
21b53a692b Fix healthcheck to use curl instead of wget for Alpine compatibility 2025-11-22 16:57:43 +01:00
Hördle Bot
9a072061a1 Add deployment script and health checks for zero-downtime updates 2025-11-22 16:56:30 +01:00
66 changed files with 6756 additions and 382 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.
@@ -47,13 +62,15 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
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

175
README.md
View File

@@ -8,23 +8,69 @@ 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.
- **Special Curation & Scheduling:**
- Erstellen von thematischen Special-Kategorien (z.B. "Weihnachtslieder", "80er Hits").
- **Zeitsteuerung:** Festlegen von Start- und Enddatum für Specials (automatische Aktivierung/Deaktivierung).
- **Kuratierung:** Angabe eines Kurators, der auf der Startseite genannt wird ("Curated by ...").
- Visueller Waveform-Editor zur präzisen Auswahl von Audio-Snippets.
- Segment-Marker zeigen Puzzle-Abschnitte (2s, 4s, 7s, etc.).
- Zoom & Pan für detaillierte Bearbeitung.
- 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.
## White Labeling
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)**
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
## 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
@@ -69,8 +115,11 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
cp docker-compose.example.yml docker-compose.yml
```
Passe die Umgebungsvariablen in der `docker-compose.yml` an:
- `ADMIN_PASSWORD`: Admin-Passwort (Standard: `admin123`)
- `TZ`: Zeitzone für täglichen Puzzle-Wechsel (Standard: `Europe/Berlin`)
- `ADMIN_PASSWORD`: Admin-Passwort als Bcrypt-Hash.
Erstelle den Hash mit: `node scripts/hash-password.js <dein-passwort>`
**Wichtig:** In `docker-compose.yml` müssen alle `$` Zeichen im Hash verdoppelt werden (`$$`), damit sie nicht als Variablen interpretiert werden!
Beispiel: `$$2b$$10$$...`
- `TZ`: Zeitzone für täglichen Puzzle-Wechsel und Datumsanzeige (Standard: `Europe/Berlin`)
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
@@ -89,7 +138,24 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
4. **Admin-Zugang:**
- URL: `/admin`
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern!)
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
5. **Special Curation & Scheduling verwenden:**
- Erstelle ein Special im Admin-Dashboard:
- Gib Name, Max Attempts und Unlock Steps ein.
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
- **Optional:** Trage einen Kurator ein.
- Weise Songs dem Special zu (über die Song-Bibliothek).
- Klicke auf "Curate" neben dem Special.
- Nutze den Waveform-Editor um den perfekten Ausschnitt zu wählen:
- **Klicken:** Positioniert die Selektion
- **Hovern:** Zeigt Vorschau der neuen Position
- **Zoom:** 🔍+ / 🔍− Buttons für detaillierte Ansicht
- **Pan:** ← / → Buttons zum Verschieben der Ansicht
- **Segment-Playback:** Teste einzelne Puzzle-Abschnitte
- **Save:** Speichere Änderungen mit dem grünen Button
- Die Spieler hören dann nur den kuratierten Ausschnitt.
- Auf der Startseite werden zukünftige Specials unter "Coming soon" angezeigt (mit Datum und Kurator).
## Nginx-Konfiguration (für Reverse Proxy)
@@ -123,6 +189,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)
@@ -140,6 +292,19 @@ Eine vollständige Beispiel-Konfiguration findest du in `nginx.conf.example`.
- `proxy_buffering off;` - Deaktiviert Buffering für große Dateien
- `client_max_body_size 50M;` - Erlaubt große Uploads
### Admin Login schlägt fehl (Docker)
**Problem:** "Wrong password" trotz korrekt generiertem Hash.
**Ursache:** Docker Compose interpretiert `$` Zeichen im Hash als Variablen.
**Lösung:**
In der `docker-compose.yml` müssen alle `$` Zeichen im Hash verdoppelt werden (`$$`).
Falsch: `$2b$10$...`
Richtig: `$$2b$$10$$...`
Das Skript `node scripts/hash-password.js <pw>` gibt nun auch direkt den passenden String für Docker Compose aus.
## Lizenz
MIT

67
WHITE_LABEL.md Normal file
View File

@@ -0,0 +1,67 @@
# White Labeling Guide
This application is designed to be easily white-labeled. You can customize the branding, colors, and configuration without modifying the core code.
## Configuration
The application is configured via environment variables. You can set these in a `.env` or `.env.local` file.
### Branding
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
### Analytics (Plausible)
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
### Credits
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_CREDITS_ENABLED` | Enable/disable footer credits (`true`/`false`). | `true` |
| `NEXT_PUBLIC_CREDITS_TEXT` | Text before the link. | `Vibe coded with ☕ and 🍺 by` |
| `NEXT_PUBLIC_CREDITS_LINK_TEXT` | Text of the link. | `@elpatron@digitalcourage.social` |
| `NEXT_PUBLIC_CREDITS_LINK_URL` | URL of the link. | `https://digitalcourage.social/@elpatron` |
## Theming
The application uses CSS variables for theming. You can override these variables in your own CSS file or by modifying `app/globals.css`.
### Key Colors
| Variable | Description | Default |
|----------|-------------|---------|
| `--primary` | Main action color (buttons). | `#000000` |
| `--secondary` | Secondary actions. | `#4b5563` |
| `--accent` | Accent color. | `#667eea` |
| `--success` | Success state (correct guess). | `#22c55e` |
| `--danger` | Error state (wrong guess). | `#ef4444` |
| `--warning` | Warning state (stars). | `#ffc107` |
| `--muted` | Muted backgrounds. | `#f3f4f6` |
### Example: Red Theme
To create a red-themed version, add this to your CSS:
```css
:root {
--primary: #dc2626;
--accent: #ef4444;
--accent-gradient: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
}
```
## Assets
To replace the logo and icons:
1. Replace `public/favicon.ico`.
2. Replace `public/icon.png` (if it exists).
3. Update `app/manifest.ts` if you have custom icon paths.

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',
@@ -30,3 +31,52 @@ export async function sendGotifyNotification(attempts: number, status: 'won' | '
console.error('Error sending Gotify notification:', error);
}
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function submitRating(songId: number, rating: number, genre?: string | null, isSpecial?: boolean, puzzleNumber?: number) {
try {
const song = await prisma.song.findUnique({ where: { id: songId } });
if (!song) throw new Error('Song not found');
const newRatingCount = song.ratingCount + 1;
const newAverageRating = ((song.averageRating * song.ratingCount) + rating) / newRatingCount;
await prisma.song.update({
where: { id: songId },
data: {
averageRating: newAverageRating,
ratingCount: newRatingCount,
},
});
// Send Gotify notification for the rating
let context = 'Global Daily Puzzle';
if (genre) {
context = isSpecial ? `Special Puzzle "${genre}"` : `Genre Puzzle "${genre}"`;
}
if (puzzleNumber) {
context += ` #${puzzleNumber}`;
}
const title = `Hördle Rating: ${rating} Stars`;
// Do not show song title/artist to avoid spoilers
const message = `${context} received a ${rating}-star rating.\nNew Average: ${newAverageRating.toFixed(2)} (${newRatingCount} ratings)`;
await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
message: message,
priority: 5,
}),
});
return { success: true, averageRating: newAverageRating };
} catch (error) {
console.error('Error submitting rating:', error);
return { success: false, error: 'Failed to submit rating' };
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,232 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import WaveformEditor from '@/components/WaveformEditor';
interface Song {
id: number;
title: string;
artist: string;
filename: string;
}
interface SpecialSong {
id: number;
songId: number;
startTime: number;
order: number | null;
song: Song;
}
interface Special {
id: number;
name: string;
subtitle?: string;
maxAttempts: number;
unlockSteps: string;
songs: SpecialSong[];
}
export default function SpecialEditorPage() {
const params = useParams();
const router = useRouter();
const specialId = params.id as string;
const [special, setSpecial] = useState<Special | null>(null);
const [selectedSongId, setSelectedSongId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useEffect(() => {
fetchSpecial();
}, [specialId]);
const fetchSpecial = async () => {
try {
const res = await fetch(`/api/specials/${specialId}`);
if (res.ok) {
const data = await res.json();
setSpecial(data);
if (data.songs.length > 0) {
setSelectedSongId(data.songs[0].songId);
// Initialize pendingStartTime with the current startTime of the first song
setPendingStartTime(data.songs[0].startTime);
}
}
} catch (error) {
console.error('Error fetching special:', error);
} finally {
setLoading(false);
}
};
const handleStartTimeChange = (newStartTime: number) => {
setPendingStartTime(newStartTime);
setHasUnsavedChanges(true);
};
const handleSave = async () => {
if (!special || !selectedSongId || pendingStartTime === null) return;
setSaving(true);
try {
const res = await fetch(`/api/specials/${specialId}/songs`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime })
});
if (res.ok) {
// Update local state
setSpecial(prev => {
if (!prev) return prev;
return {
...prev,
songs: prev.songs.map(ss =>
ss.songId === selectedSongId ? { ...ss, startTime: pendingStartTime } : ss
)
};
});
setHasUnsavedChanges(false);
setPendingStartTime(null); // Reset pending state after saving
}
} catch (error) {
console.error('Error updating start time:', error);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>Loading...</p>
</div>
);
}
if (!special) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>Special not found</p>
<button onClick={() => router.push('/admin')}>Back to Admin</button>
</div>
);
}
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
const unlockSteps = JSON.parse(special.unlockSteps);
const totalDuration = unlockSteps[unlockSteps.length - 1];
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ marginBottom: '2rem' }}>
<button
onClick={() => router.push('/admin')}
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
marginBottom: '1rem'
}}
>
Back to Admin
</button>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
Edit Special: {special.name}
</h1>
{special.subtitle && (
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
{special.subtitle}
</p>
)}
<p style={{ color: '#666', marginTop: '0.5rem' }}>
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
</p>
</div>
{special.songs.length === 0 ? (
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
<p>No songs assigned to this special yet.</p>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
Go back to the admin dashboard to add songs to this special.
</p>
</div>
) : (
<div>
<div style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Select Song to Curate
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
{special.songs.map(ss => (
<div
key={ss.songId}
onClick={() => setSelectedSongId(ss.songId)}
style={{
padding: '1rem',
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
color: selectedSongId === ss.songId ? 'white' : 'black',
borderRadius: '0.5rem',
cursor: 'pointer',
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
}}
>
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
Start: {ss.startTime}s
</div>
</div>
))}
</div>
</div>
{selectedSpecialSong && (
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Curate: {selectedSpecialSong.song.title}
</h2>
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.
</p>
<button
onClick={handleSave}
disabled={!hasUnsavedChanges || saving}
style={{
padding: '0.5rem 1.5rem',
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
color: hasUnsavedChanges ? 'white' : '#9ca3af',
border: 'none',
borderRadius: '0.5rem',
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
fontWeight: 'bold',
fontSize: '0.875rem',
whiteSpace: 'nowrap'
}}
>
{saving ? '💾 Saving...' : hasUnsavedChanges ? '💾 Save Changes' : '✓ Saved'}
</button>
</div>
<WaveformEditor
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
duration={totalDuration}
unlockSteps={unlockSteps}
onStartTimeChange={handleStartTimeChange}
/>
</div>
</div>
)}
</div>
)}
</div>
);
}

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();
@@ -38,11 +39,24 @@ export async function GET() {
title: puzzle.song.title,
artist: puzzle.song.artist,
filename: puzzle.song.filename,
audioUrl: `/uploads/${puzzle.song.filename}`
audioUrl: `/api/audio/${puzzle.song.filename}`
}
}));
return NextResponse.json(formattedPuzzles);
// Filter out duplicates (keep only the first one per category)
// This matches the behavior of getOrCreateDailyPuzzle which uses findFirst
const uniquePuzzles = [];
const seenCategories = new Set();
for (const puzzle of formattedPuzzles) {
const key = `${puzzle.categoryType}-${puzzle.genreId || 'null'}-${puzzle.specialId || 'null'}`;
if (!seenCategories.has(key)) {
seenCategories.add(key);
uniquePuzzles.push(puzzle);
}
}
return NextResponse.json(uniquePuzzles);
} catch (error) {
console.error('Error fetching daily puzzles:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
@@ -50,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,16 +1,26 @@
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();
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; // Default for dev if not set
// Default is hash for 'admin123'
const adminPasswordHash = process.env.ADMIN_PASSWORD || '$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq';
if (password === adminPassword) {
const isValid = await bcrypt.compare(password, adminPasswordHash);
if (isValid) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,138 @@
import { NextRequest, NextResponse } from 'next/server';
import { stat } from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Security: Prevent path traversal attacks
// 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 });
}
const stats = await stat(filePath);
const fileSize = stats.size;
const range = request.headers.get('range');
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const stream = createReadStream(filePath, { start, end });
// Convert Node stream to Web stream
const readable = new ReadableStream({
start(controller) {
let isClosed = false;
stream.on('data', (chunk: any) => {
if (isClosed) return;
try {
controller.enqueue(chunk);
} catch (e) {
isClosed = true;
stream.destroy();
}
});
stream.on('end', () => {
if (isClosed) return;
isClosed = true;
controller.close();
});
stream.on('error', (err: any) => {
if (isClosed) return;
isClosed = true;
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});
return new NextResponse(readable, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize.toString(),
'Content-Type': 'audio/mpeg',
'Cache-Control': 'public, max-age=3600, must-revalidate',
},
});
} else {
const stream = createReadStream(filePath);
// Convert Node stream to Web stream
const readable = new ReadableStream({
start(controller) {
let isClosed = false;
stream.on('data', (chunk: any) => {
if (isClosed) return;
try {
controller.enqueue(chunk);
} catch (e) {
isClosed = true;
stream.destroy();
}
});
stream.on('end', () => {
if (isClosed) return;
isClosed = true;
controller.close();
});
stream.on('error', (err: any) => {
if (isClosed) return;
isClosed = true;
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});
return new NextResponse(readable, {
status: 200,
headers: {
'Content-Length': fileSize.toString(),
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600, must-revalidate',
},
});
}
} catch (error) {
console.error('Error serving audio file:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

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

5
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok' }, { status: 200 });
}

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,20 +3,30 @@ 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' },
include: {
puzzles: true,
genres: true,
specials: true,
specials: {
include: {
special: true
}
},
},
});
// Map to include activation count
// Map to include activation count and flatten specials
const songsWithActivations = songs.map(song => ({
id: song.id,
title: song.title,
@@ -24,27 +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,
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;
@@ -66,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
@@ -108,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
@@ -142,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 }
});
@@ -163,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 });
@@ -172,22 +274,66 @@ 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 }))
};
}
if (specialIds) {
data.specials = {
set: specialIds.map((sId: number) => ({ id: sId }))
};
// Handle SpecialSong relations separately
if (specialIds !== undefined) {
// First, get current special assignments
const currentSpecials = await prisma.specialSong.findMany({
where: { songId: Number(id) }
});
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const newSpecialIds = specialIds as number[];
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
if (toDelete.length > 0) {
await prisma.specialSong.deleteMany({
where: {
songId: Number(id),
specialId: { in: toDelete }
}
});
}
// Add new specials
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
if (toAdd.length > 0) {
await prisma.specialSong.createMany({
data: toAdd.map(specialId => ({
songId: Number(id),
specialId,
startTime: 0
}))
});
}
}
const updatedSong = await prisma.song.update({
where: { id: Number(id) },
data,
include: { genres: true, specials: true }
include: {
genres: true,
specials: {
include: {
special: true
}
}
}
});
return NextResponse.json(updatedSong);
@@ -198,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

@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const specialId = parseInt(id);
const special = await prisma.special.findUnique({
where: { id: specialId },
include: {
songs: {
include: {
song: true
},
orderBy: {
order: 'asc'
}
}
}
});
if (!special) {
return NextResponse.json({ error: 'Special not found' }, { status: 404 });
}
return NextResponse.json(special);
} catch (error) {
console.error('Error fetching special:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const specialId = parseInt(id);
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
const special = await prisma.special.update({
where: { id: specialId },
data: {
name,
maxAttempts,
unlockSteps: typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps),
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
}
});
return NextResponse.json(special);
} catch (error) {
console.error('Error updating special:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const specialId = parseInt(id);
const { songId, startTime = 0, order } = await request.json();
const specialSong = await prisma.specialSong.create({
data: {
specialId,
songId,
startTime,
order
},
include: {
song: true
}
});
return NextResponse.json(specialSong);
} catch (error) {
console.error('Error adding song to special:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const specialId = parseInt(id);
const { songId, startTime, order } = await request.json();
const specialSong = await prisma.specialSong.update({
where: {
specialId_songId: {
specialId,
songId
}
},
data: {
startTime,
order
},
include: {
song: true
}
});
return NextResponse.json(specialSong);
} catch (error) {
console.error('Error updating special song:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const specialId = parseInt(id);
const { songId } = await request.json();
await prisma.specialSong.delete({
where: {
specialId_songId: {
specialId,
songId
}
}
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error removing song from special:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -1,31 +1,49 @@
import { PrismaClient, Special } from '@prisma/client';
import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function GET() {
const specials = await prisma.special.findMany({
orderBy: { name: 'asc' },
include: {
_count: {
select: { songs: true }
}
}
});
return NextResponse.json(specials);
}
export async function POST(request: Request) {
const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]' } = 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,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
},
});
return NextResponse.json(special);
}
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 });
@@ -35,7 +53,11 @@ export async function DELETE(request: Request) {
}
export async function PUT(request: Request) {
const { id, name, maxAttempts, unlockSteps } = 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 });
}
@@ -43,8 +65,12 @@ 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,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
},
});
return NextResponse.json(updated);

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

@@ -2,6 +2,24 @@
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
/* Theme Colors */
--primary: #000000;
--primary-foreground: #ffffff;
--secondary: #4b5563;
--secondary-foreground: #ffffff;
--accent: #667eea;
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--success: #22c55e;
--success-foreground: #ffffff;
--danger: #ef4444;
--danger-foreground: #ffffff;
--warning: #ffc107;
--muted: #f3f4f6;
--muted-foreground: #6b7280;
--border: #e5e7eb;
--input: #d1d5db;
--ring: #000000;
}
body {
@@ -51,13 +69,13 @@ body {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #666;
color: var(--muted-foreground);
margin-bottom: 0.5rem;
}
/* Audio Player */
.audio-player {
background: #f3f4f6;
background: var(--muted);
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
@@ -73,8 +91,8 @@ body {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: #000;
color: #fff;
background: var(--primary);
color: var(--primary-foreground);
border: none;
display: flex;
align-items: center;
@@ -85,19 +103,20 @@ body {
.play-button:hover {
background: #333;
/* Keep for now or add --primary-hover */
}
.progress-bar-container {
flex: 1;
height: 0.5rem;
background: #d1d5db;
background: var(--input);
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #22c55e;
background: var(--success);
transition: width 0.1s linear;
}
@@ -114,7 +133,7 @@ body {
gap: 0.5rem;
padding: 0.5rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.875rem;
}
@@ -125,7 +144,7 @@ body {
}
.guess-text {
color: #ef4444;
color: var(--danger);
/* Red for wrong */
}
@@ -135,7 +154,7 @@ body {
}
.guess-text.correct {
color: #22c55e;
color: var(--success);
}
/* Input */
@@ -148,14 +167,14 @@ body {
.guess-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border: 1px solid var(--input);
border-radius: 0.25rem;
font-size: 1rem;
box-sizing: border-box;
}
.guess-input:focus {
outline: 2px solid #000;
outline: 2px solid var(--ring);
border-color: transparent;
}
@@ -163,7 +182,7 @@ body {
position: absolute;
width: 100%;
background: #fff;
border: 1px solid #d1d5db;
border: 1px solid var(--input);
border-radius: 0.25rem;
margin-top: 0.25rem;
max-height: 15rem;
@@ -177,11 +196,11 @@ body {
.suggestion-item {
padding: 0.75rem;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
border-bottom: 1px solid var(--muted);
}
.suggestion-item:hover {
background: #f3f4f6;
background: var(--muted);
}
.suggestion-title {
@@ -190,14 +209,14 @@ body {
.suggestion-artist {
font-size: 0.875rem;
color: #666;
color: var(--muted-foreground);
}
.skip-button {
width: 100%;
padding: 1rem 1.5rem;
margin-top: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--accent-gradient);
color: white;
border: none;
border-radius: 0.5rem;
@@ -246,7 +265,7 @@ body {
}
.admin-card {
background: #f3f4f6;
background: var(--muted);
padding: 2rem;
border-radius: 0.5rem;
}
@@ -265,33 +284,80 @@ body {
.form-input {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border: 1px solid var(--input);
border-radius: 0.25rem;
box-sizing: border-box;
}
.btn-primary {
background: #000;
color: #fff;
background: var(--primary);
color: var(--primary-foreground);
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
text-decoration: none;
font-size: 0.875rem;
}
.btn-primary:hover {
background: #333;
}
.btn-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
font-size: 0.875rem;
}
.btn-secondary:hover {
background: #374151;
}
.btn-danger {
background: var(--danger);
color: var(--danger-foreground);
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.5rem;
box-sizing: border-box;
font-size: 0.875rem;
}
.btn-danger:hover {
background: #dc2626;
}
/* Footer */
.app-footer {
margin-top: auto;
padding: 2rem 1rem 1rem;
text-align: center;
font-size: 0.875rem;
color: #666;
border-top: 1px solid #e5e7eb;
color: var(--muted-foreground);
border-top: 1px solid var(--border);
width: 100%;
}
@@ -300,7 +366,7 @@ body {
}
.app-footer a {
color: #000;
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
@@ -328,7 +394,7 @@ body {
font-size: 0.875rem;
text-align: center;
margin: 0 0 1rem 0;
color: #666;
color: var(--muted-foreground);
}
.statistics-grid {
@@ -344,7 +410,7 @@ body {
padding: 0.75rem 0.5rem;
background: rgba(255, 255, 255, 0.8);
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
border: 1px solid var(--border);
}
.stat-badge {
@@ -354,7 +420,7 @@ body {
.stat-label {
font-size: 0.75rem;
color: #666;
color: var(--muted-foreground);
margin-bottom: 0.25rem;
text-align: center;
}
@@ -362,5 +428,51 @@ body {
.stat-count {
font-size: 1.25rem;
font-weight: bold;
color: #000;
color: var(--primary);
}
/* 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

@@ -1,7 +1,10 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css";
import { config } from "@/lib/config";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@@ -13,17 +16,20 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Hördle",
description: "Daily music guessing game - Guess the song from short audio clips",
title: config.appName,
description: config.appDescription,
};
export const viewport: Viewport = {
themeColor: "#000000",
themeColor: config.colors.themeColor,
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
import InstallPrompt from "@/components/InstallPrompt";
import AppFooter from "@/components/AppFooter";
export default function RootLayout({
children,
}: Readonly<{
@@ -31,17 +37,18 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<head>
<Script
defer
data-domain={config.plausibleDomain}
src={config.plausibleScriptSrc}
strategy="beforeInteractive"
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<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>
<InstallPrompt />
<AppFooter />
</body>
</html>
);

View File

@@ -1,14 +1,15 @@
import type { MetadataRoute } from 'next'
import { config } from '@/lib/config'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Hördle',
short_name: 'Hördle',
description: 'Daily music guessing game - Guess the song from short audio clips',
name: config.appName,
short_name: config.appName,
description: config.appDescription,
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
background_color: config.colors.backgroundColor,
theme_color: config.colors.themeColor,
icons: [
{
src: '/favicon.ico',

View File

@@ -1,4 +1,6 @@
import Game from '@/components/Game';
import NewsSection from '@/components/NewsSection';
import OnboardingTour from '@/components/OnboardingTour';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
@@ -9,44 +11,97 @@ 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();
const activeSpecials = specials.filter(s => {
const isStarted = !s.launchDate || s.launchDate <= now;
const isEnded = s.endDate && s.endDate < now;
return isStarted && !isEnded;
});
const upcomingSpecials = specials.filter(s => {
return s.launchDate && s.launchDate > now;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div id="tour-genres" 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 */}
{genres.length > 0 && specials.length > 0 && (
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
{/* Active Specials */}
{activeSpecials.map(s => (
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div className="tooltip">
<Link
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
</div>
{s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}>
Curated by {s.curator}
</span>
)}
</div>
))}
</div>
{/* Upcoming Specials */}
{upcomingSpecials.length > 0 && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Coming soon: {upcomingSpecials.map(s => (
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
{s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
</span>
))}
</div>
)}
</div>
<div id="tour-news">
<NewsSection />
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />
<OnboardingTour />
</>
);
}

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';
@@ -14,10 +15,48 @@ interface PageProps {
export default async function SpecialPage({ params }: PageProps) {
const { name } = await params;
const decodedName = decodeURIComponent(name);
const currentSpecial = await prisma.special.findUnique({
where: { name: decodedName }
});
const now = new Date();
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
if (!currentSpecial || !isStarted) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Special Not Available</h1>
<p>This special has not launched yet or does not exist.</p>
<Link href="/">Go Home</Link>
</div>
);
}
if (isEnded) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>Special Ended</h1>
<p>This special event has ended.</p>
<Link href="/">Go Home</Link>
</div>
);
}
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const activeSpecials = specials.filter(s => {
const sStarted = !s.launchDate || s.launchDate <= now;
const sEnded = s.endDate && s.endDate < now;
return sStarted && !sEnded;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
@@ -39,12 +78,12 @@ export default async function SpecialPage({ 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,9 +98,11 @@ export default async function SpecialPage({ params }: PageProps) {
))}
</div>
</div>
<NewsSection />
<Game
dailyPuzzle={dailyPuzzle}
genre={decodedName}
isSpecial={true}
maxAttempts={dailyPuzzle?.maxAttempts}
unlockSteps={dailyPuzzle?.unlockSteps}
/>

36
components/AppFooter.tsx Normal file
View File

@@ -0,0 +1,36 @@
'use client';
import { config } from '@/lib/config';
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(''));
}, []);
if (!config.credits.enabled) return null;
return (
<footer className="app-footer">
<p>
{config.credits.text}{' '}
<a href={config.credits.linkUrl} target="_blank" rel="noopener noreferrer">
{config.credits.linkText}
</a>
{version && (
<>
{' '}·{' '}
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
{version}
</span>
</>
)}
</p>
</footer>
);
}

View File

@@ -1,42 +1,120 @@
'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, 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);
const [processedSrc, setProcessedSrc] = useState(src);
const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState(unlockedSeconds);
useEffect(() => {
console.log('[AudioPlayer] MOUNTED');
return () => console.log('[AudioPlayer] UNMOUNTED');
}, []);
useEffect(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setIsPlaying(false);
setProgress(0);
// Check if props changed compared to what we last processed
const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
if (autoPlay) {
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
})
.catch(error => {
console.log("Autoplay prevented:", error);
setIsPlaying(false);
});
if (hasChanged) {
audioRef.current.pause();
let startPos = startTime;
// If same song but more time unlocked, start from where previous segment ended
if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
startPos = startTime + processedUnlockedSeconds;
}
const targetPos = startPos;
audioRef.current.currentTime = targetPos;
// Ensure position is set correctly even if browser resets it
setTimeout(() => {
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
audioRef.current.currentTime = targetPos;
}
}, 50);
setIsPlaying(false);
// Calculate initial progress
const initialElapsed = startPos - startTime;
const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0;
setProgress(Math.min(initialPercent, 100));
setHasPlayedOnce(false); // Reset for new segment
onHasPlayedChange?.(false); // Notify parent
// Update processed state
setProcessedSrc(src);
setProcessedUnlockedSeconds(unlockedSeconds);
if (autoPlay) {
// Delay play slightly to ensure currentTime sticks
setTimeout(() => {
const playPromise = audioRef.current?.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
})
.catch(error => {
console.log("Autoplay prevented:", error);
setIsPlaying(false);
});
}
}, 150);
}
}
}
}, [src, unlockedSeconds, autoPlay]);
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
// Expose play method to parent component
useImperativeHandle(ref, () => ({
play: () => {
if (!audioRef.current) return;
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
setIsPlaying(true);
onPlay?.();
if (!hasPlayedOnce) {
setHasPlayedOnce(true);
onHasPlayedChange?.(true);
}
})
.catch(error => {
console.error("Play failed:", error);
setIsPlaying(false);
});
}
}
}));
const togglePlay = () => {
if (!audioRef.current) return;
@@ -46,6 +124,13 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
} else {
audioRef.current.play();
onPlay?.();
if (hasPlayedOnce) {
onReplay?.();
} else {
setHasPlayedOnce(true);
onHasPlayedChange?.(true); // Notify parent
}
}
setIsPlaying(!isPlaying);
};
@@ -54,12 +139,13 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
if (!audioRef.current) return;
const current = audioRef.current.currentTime;
const percent = (current / unlockedSeconds) * 100;
const elapsed = current - startTime;
const percent = (elapsed / unlockedSeconds) * 100;
setProgress(Math.min(percent, 100));
if (current >= unlockedSeconds) {
if (elapsed >= unlockedSeconds) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.currentTime = startTime;
setIsPlaying(false);
setProgress(0);
}
@@ -100,4 +186,10 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
</div>
</div>
);
}
});
AudioPlayer.displayName = 'AudioPlayer';
export default AudioPlayer;

View File

@@ -1,39 +1,80 @@
'use client';
import { useEffect, useState } from 'react';
import AudioPlayer from './AudioPlayer';
import { config } from '@/lib/config';
import { useEffect, useState, useRef } from 'react';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput';
import Statistics from './Statistics';
import { useGameState } from '../lib/gameState';
import { sendGotifyNotification } from '../app/actions';
import { sendGotifyNotification, submitRating } from '../app/actions';
// Plausible Analytics
declare global {
interface Window {
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
}
}
interface GameProps {
dailyPuzzle: {
id: number;
puzzleNumber: number;
audioUrl: string;
songId: number;
title: string;
artist: string;
coverImage: string | null;
releaseYear?: number | null;
startTime?: number;
} | null;
genre?: string | null;
isSpecial?: boolean;
maxAttempts?: number;
unlockSteps?: number[];
}
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle, genre = null, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('Share Result');
const [shareText, setShareText] = useState('🔗 Share');
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
const [timeUntilNext, setTimeUntilNext] = useState('');
const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false);
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
useEffect(() => {
const updateCountdown = () => {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setHours(24, 0, 0, 0);
const diff = tomorrow.getTime() - now.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
setTimeUntilNext(`${hours}h ${minutes}m`);
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
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]);
@@ -41,6 +82,17 @@ export default function Game({ dailyPuzzle, genre = null, maxAttempts = 7, unloc
setLastAction(null);
}, [dailyPuzzle?.id]);
useEffect(() => {
if (dailyPuzzle) {
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
if (ratedPuzzles.includes(dailyPuzzle.id)) {
setHasRated(true);
} else {
setHasRated(false);
}
}
}, [dailyPuzzle]);
if (!dailyPuzzle) return (
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
<h2>No Puzzle Available</h2>
@@ -52,99 +104,260 @@ export default function Game({ dailyPuzzle, genre = null, maxAttempts = 7, unloc
if (!gameState) return <div>Loading state...</div>;
const handleGuess = (song: any) => {
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);
// Track puzzle solved event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length + 1,
score: gameState.score + 20, // Include the win bonus
outcome: 'won'
}
});
}
// Notification sent after year guess or skip
if (!dailyPuzzle.releaseYear) {
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
}
} 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);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: maxAttempts,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
}
setTimeout(() => setIsProcessingGuess(false), 500);
};
const handleStartAudio = () => {
// This will be called when user clicks "Start" button on first attempt
// Trigger the audio player to start playing
audioPlayerRef.current?.play();
setHasPlayedAudio(true);
};
const handleSkip = () => {
// If user hasn't played audio yet on first attempt, start it instead of skipping
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
handleStartAudio();
return;
}
setLastAction('SKIP');
addGuess("SKIPPED", false);
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: maxAttempts,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
};
const handleGiveUp = () => {
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);
// Track puzzle lost event
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length + 1,
score: 0,
outcome: 'lost'
}
});
}
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
};
const handleYearGuess = (year: number) => {
const correct = year === dailyPuzzle.releaseYear;
addYearBonus(correct);
setShowYearModal(false);
// Update the puzzle_solved event with year bonus result
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length,
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
outcome: 'won',
year_bonus: correct ? 'correct' : 'incorrect'
}
});
}
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
};
const handleYearSkip = () => {
skipYearBonus();
setShowYearModal(false);
// Update the puzzle_solved event with year bonus result
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('puzzle_solved', {
props: {
genre: genre || 'Global',
attempts: gameState.guesses.length,
score: gameState.score, // Score already includes win bonus
outcome: 'won',
year_bonus: 'skipped'
}
});
}
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
};
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
const handleShare = () => {
const handleShare = async () => {
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 {
// Wrong or skipped
} else if (gameState.guesses[i] === 'SKIPPED') {
emojiGrid += '⬛';
} else {
emojiGrid += '🟥';
}
} else {
// Unused attempts
emojiGrid += '⬜';
}
}
const speaker = hasWon ? '🔉' : '🔇';
const genreText = genre ? `Genre: ${genre}\n` : '';
const text = `Hördle #${dailyPuzzle.id}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`;
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
// Fallback method for copying to clipboard
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
let shareUrl = `https://${config.domain}`;
if (genre) {
if (isSpecial) {
shareUrl += `/special/${encodeURIComponent(genre)}`;
} else {
shareUrl += `/${encodeURIComponent(genre)}`;
}
}
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile && navigator.share) {
try {
await navigator.share({
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
text: text,
});
setShareText('✓ Shared!');
setTimeout(() => setShareText('🔗 Share'), 2000);
return;
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
}
}
}
try {
document.execCommand('copy');
setShareText('Copied!');
setTimeout(() => setShareText('Share Result'), 2000);
await navigator.clipboard.writeText(text);
setShareText('Copied!');
setTimeout(() => setShareText('🔗 Share'), 2000);
} catch (err) {
console.error('Failed to copy:', err);
setShareText('Copy failed');
setTimeout(() => setShareText('Share Result'), 2000);
} finally {
document.body.removeChild(textarea);
console.error('Clipboard failed:', err);
setShareText('✗ Failed');
setTimeout(() => setShareText('🔗 Share'), 2000);
}
};
const handleRatingSubmit = async (rating: number) => {
if (!dailyPuzzle) return;
try {
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
setHasRated(true);
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
ratedPuzzles.push(dailyPuzzle.id);
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
}
} catch (error) {
console.error('Failed to submit rating', error);
}
};
return (
<div className="container">
<header className="header">
<h1 className="title">Hördle #{dailyPuzzle.id}{genre ? ` / ${genre}` : ''}</h1>
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
Next puzzle in: {timeUntilNext}
</div>
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar">
<div id="tour-status" className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span>
</div>
<AudioPlayer
src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds}
autoPlay={lastAction === 'SKIP'}
/>
<div id="tour-score">
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
</div>
<div id="tour-player">
<AudioPlayer
ref={audioPlayerRef}
src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds}
startTime={dailyPuzzle.startTime}
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onReplay={addReplay}
onHasPlayedChange={setHasPlayedAudio}
/>
</div>
</div>
<div className="guess-list">
@@ -163,13 +376,19 @@ export default function Game({ dailyPuzzle, genre = null, maxAttempts = 7, unloc
{!hasWon && !hasLost && (
<>
<GuessInput onGuess={handleGuess} disabled={false} />
{gameState.guesses.length < 6 ? (
<div id="tour-input">
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
</div>
{gameState.guesses.length < maxAttempts - 1 ? (
<button
id="tour-controls"
onClick={handleSkip}
className="skip-button"
>
Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
{gameState.guesses.length === 0 && !hasPlayedAudio
? 'Start'
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
}
</button>
) : (
<button
@@ -186,65 +405,295 @@ export default function Game({ dailyPuzzle, genre = null, maxAttempts = 7, unloc
</>
)}
{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 ? 'var(--success)' : 'var(--danger)' }}>
Score: {gameState.score}
</div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
<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: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', 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>
<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>
)}
{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>
{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: 'var(--muted)',
borderRadius: '0.5rem',
fontSize: '0.9rem',
fontFamily: 'monospace',
cursor: 'help'
}}>
<span style={{ color: 'var(--muted-foreground)' }}>{expression} = </span>
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
</div>
);
}
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const [options, setOptions] = useState<number[]>([]);
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
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]);
const handleGuess = (year: number) => {
const correct = year === correctYear;
setFeedback({ show: true, correct, guessedYear: year });
// Close modal after showing feedback
setTimeout(() => {
onGuess(year);
}, 2500);
};
const handleSkip = () => {
setFeedback({ show: true, correct: false });
setTimeout(() => {
onSkip();
}, 2000);
};
return (
<div style={{
position: 'fixed',
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)'
}}>
{!feedback.show ? (
<>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>Guess the release year for <strong style={{ color: 'var(--success)' }}>+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={() => handleGuess(year)}
style={{
padding: '0.75rem',
background: 'var(--muted)',
border: '2px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: 'var(--secondary)',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
>
{year}
</button>
))}
</div>
<button
onClick={handleSkip}
style={{
background: 'none',
border: 'none',
color: 'var(--muted-foreground)',
textDecoration: 'underline',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
Skip Bonus
</button>
</>
) : (
<div style={{ padding: '2rem 0' }}>
{feedback.guessedYear ? (
feedback.correct ? (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>Correct!</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 Points!</p>
</>
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>Not quite!</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>You guessed {feedback.guessedYear}</p>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
</>
)
) : (
<>
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}></div>
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>Skipped</h3>
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
</>
)}
</div>
)}
</div>
</div>
);
}
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
const [hover, setHover] = useState(0);
const [rating, setRating] = useState(0);
if (hasRated) {
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>Thanks for rating!</div>;
}
return (
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>Rate this puzzle:</span>
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
{[...Array(5)].map((_, index) => {
const ratingValue = index + 1;
return (
<button
key={index}
type="button"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '2rem',
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
transition: 'color 0.2s',
padding: '0 0.25rem'
}}
onClick={() => {
setRating(ratingValue);
onRate(ratingValue);
}}
onMouseEnter={() => setHover(ratingValue)}
onMouseLeave={() => setHover(0)}
>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
'use client';
import { useState, useEffect } from 'react';
export default function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
useEffect(() => {
// Check if already in standalone mode
const isStandaloneMode = window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone;
setIsStandalone(isStandaloneMode);
// Check if iOS
const userAgent = window.navigator.userAgent.toLowerCase();
const isIosDevice = /iphone|ipad|ipod/.test(userAgent);
setIsIOS(isIosDevice);
// Check if already dismissed
const isDismissed = localStorage.getItem('installPromptDismissed');
if (!isStandaloneMode && !isDismissed) {
if (isIosDevice) {
// Show prompt for iOS immediately if not dismissed
setShowPrompt(true);
} else {
// For Android/Desktop, wait for beforeinstallprompt
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
setShowPrompt(true);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
};
}
}
}, []);
const handleInstallClick = async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setDeferredPrompt(null);
setShowPrompt(false);
}
}
};
const handleDismiss = () => {
setShowPrompt(false);
localStorage.setItem('installPromptDismissed', 'true');
};
if (!showPrompt) return null;
return (
<div style={{
position: 'fixed',
bottom: '20px',
left: '20px',
right: '20px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
padding: '1rem',
borderRadius: '1rem',
boxShadow: '0 10px 25px rgba(0,0,0,0.2)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
border: '1px solid rgba(0,0,0,0.1)',
animation: 'slideUp 0.5s ease-out'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div>
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>Install Hördle App</h3>
<p style={{ fontSize: '0.875rem', color: '#666' }}>
Install the app for a better experience and quick access!
</p>
</div>
<button
onClick={handleDismiss}
style={{
background: 'none',
border: 'none',
fontSize: '1.25rem',
cursor: 'pointer',
padding: '0.25rem',
color: '#999'
}}
>
×
</button>
</div>
{isIOS ? (
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
Tap <span style={{ fontSize: '1.2rem' }}>share</span> then "Add to Home Screen" <span style={{ fontSize: '1.2rem' }}>+</span>
</div>
) : (
<button
onClick={handleInstallClick}
style={{
background: '#4f46e5',
color: 'white',
border: 'none',
padding: '0.75rem',
borderRadius: '0.5rem',
fontWeight: 'bold',
cursor: 'pointer',
marginTop: '0.5rem'
}}
>
Install App
</button>
)}
<style jsx>{`
@keyframes slideUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
</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

@@ -0,0 +1,109 @@
'use client';
import { useEffect } from 'react';
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
export default function OnboardingTour() {
useEffect(() => {
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
if (hasCompletedOnboarding) {
return;
}
const driverObj = driver({
showProgress: true,
animate: true,
allowClose: true,
doneBtnText: 'Done',
nextBtnText: 'Next',
prevBtnText: 'Previous',
onDestroyed: () => {
localStorage.setItem('hoerdle_onboarding_completed', 'true');
},
steps: [
{
element: '#tour-genres',
popover: {
title: 'Genres & Specials',
description: 'Choose a specific genre or a curated special event here.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-news',
popover: {
title: 'News',
description: 'Stay updated with the latest news and announcements.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-title',
popover: {
title: 'Hördle',
description: 'This is the daily puzzle. One new song every day per genre.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-status',
popover: {
title: 'Attempts',
description: 'You have a limited number of attempts to guess the song.',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-score',
popover: {
title: 'Score',
description: 'Your current score. Try to keep it high!',
side: 'bottom',
align: 'start'
}
},
{
element: '#tour-player',
popover: {
title: 'Player',
description: 'Listen to the snippet. Each additional play reduces your potential score.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-input',
popover: {
title: 'Input',
description: 'Type your guess here. Search for artist or title.',
side: 'top',
align: 'start'
}
},
{
element: '#tour-controls',
popover: {
title: 'Controls',
description: 'Start the music or skip to the next snippet if you\'re stuck.',
side: 'top',
align: 'start'
}
}
]
});
// Small delay to ensure DOM is ready
setTimeout(() => {
driverObj.drive();
}, 1000);
}, []);
return null;
}

View File

@@ -0,0 +1,440 @@
'use client';
import { useEffect, useRef, useState } from 'react';
interface WaveformEditorProps {
audioUrl: string;
startTime: number;
duration: number; // Total puzzle duration (e.g., 60s)
unlockSteps: number[]; // e.g., [2, 4, 7, 11, 16, 30, 60]
onStartTimeChange: (newStartTime: number) => void;
}
export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
const [audioDuration, setAudioDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [playingSegment, setPlayingSegment] = useState<number | null>(null);
const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in
const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning
const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds
const [hoverPreviewTime, setHoverPreviewTime] = useState<number | null>(null); // Preview position on hover
const audioContextRef = useRef<AudioContext | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
const playbackStartTimeRef = useRef<number>(0); // When playback started
const playbackOffsetRef = useRef<number>(0); // Offset in the audio file
const animationFrameRef = useRef<number | null>(null);
useEffect(() => {
const loadAudio = async () => {
try {
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
audioContextRef.current = audioContext;
const buffer = await audioContext.decodeAudioData(arrayBuffer);
setAudioBuffer(buffer);
setAudioDuration(buffer.duration);
} catch (error) {
console.error('Error loading audio:', error);
}
};
loadAudio();
return () => {
if (sourceRef.current) {
sourceRef.current.stop();
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [audioUrl]);
useEffect(() => {
if (!audioBuffer || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// Calculate visible range based on zoom and offset
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration);
// Clear canvas
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, width, height);
// Draw waveform for visible range
const data = audioBuffer.getChannelData(0);
const samplesPerPixel = Math.ceil((data.length * visibleDuration / audioDuration) / width);
const startSample = Math.floor(data.length * visibleStart / audioDuration);
const amp = height / 2;
ctx.fillStyle = '#4f46e5';
for (let i = 0; i < width; i++) {
let min = 1.0;
let max = -1.0;
const sampleIndex = startSample + (i * samplesPerPixel);
for (let j = 0; j < samplesPerPixel && sampleIndex + j < data.length; j++) {
const datum = data[sampleIndex + j];
if (datum < min) min = datum;
if (datum > max) max = datum;
}
ctx.fillRect(i, (1 + min) * amp, 1, Math.max(1, (max - min) * amp));
}
// Draw selection overlay
const selectionStartPx = ((startTime - visibleStart) / visibleDuration) * width;
const selectionWidthPx = (duration / visibleDuration) * width;
if (selectionStartPx + selectionWidthPx > 0 && selectionStartPx < width) {
ctx.fillStyle = 'rgba(79, 70, 229, 0.3)';
ctx.fillRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height);
// Draw selection borders
ctx.strokeStyle = '#4f46e5';
ctx.lineWidth = 2;
ctx.strokeRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height);
}
// Draw segment markers (vertical lines)
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
let cumulativeTime = 0;
unlockSteps.forEach((step, index) => {
const segmentTime = startTime + cumulativeTime;
const segmentPx = ((segmentTime - visibleStart) / visibleDuration) * width;
if (segmentPx >= 0 && segmentPx <= width) {
ctx.beginPath();
ctx.moveTo(segmentPx, 0);
ctx.lineTo(segmentPx, height);
ctx.stroke();
// Draw segment number
ctx.setLineDash([]);
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 12px sans-serif';
ctx.fillText(`${index + 1}`, segmentPx + 3, 15);
ctx.setLineDash([5, 5]);
}
cumulativeTime = step;
});
ctx.setLineDash([]);
// Draw hover preview (semi-transparent)
if (hoverPreviewTime !== null) {
const previewStartPx = ((hoverPreviewTime - visibleStart) / visibleDuration) * width;
const previewWidthPx = (duration / visibleDuration) * width;
if (previewStartPx + previewWidthPx > 0 && previewStartPx < width) {
ctx.fillStyle = 'rgba(16, 185, 129, 0.2)'; // Light green
ctx.fillRect(Math.max(0, previewStartPx), 0, Math.min(previewWidthPx, width - previewStartPx), height);
// Draw preview borders
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.strokeRect(Math.max(0, previewStartPx), 0, Math.min(previewWidthPx, width - previewStartPx), height);
ctx.setLineDash([]);
}
}
// Draw playback cursor
if (playbackPosition !== null) {
const cursorPx = ((playbackPosition - visibleStart) / visibleDuration) * width;
if (cursorPx >= 0 && cursorPx <= width) {
ctx.strokeStyle = '#10b981'; // Green
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cursorPx, 0);
ctx.lineTo(cursorPx, height);
ctx.stroke();
// Draw playhead triangle
ctx.fillStyle = '#10b981';
ctx.beginPath();
ctx.moveTo(cursorPx, 0);
ctx.lineTo(cursorPx - 5, 10);
ctx.lineTo(cursorPx + 5, 10);
ctx.closePath();
ctx.fill();
}
}
}, [audioBuffer, startTime, duration, audioDuration, zoom, viewOffset, unlockSteps, playbackPosition, hoverPreviewTime]);
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current || !audioDuration) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const clickedTime = visibleStart + (x / rect.width) * visibleDuration;
// Center the selection on the clicked point
let newStartTime = clickedTime - (duration / 2);
// Clamp to valid range
newStartTime = Math.max(0, Math.min(newStartTime, audioDuration - duration));
onStartTimeChange(Math.floor(newStartTime));
};
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current || !audioDuration) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const hoveredTime = visibleStart + (x / rect.width) * visibleDuration;
// Calculate where the selection would be centered on this point
let previewStartTime = hoveredTime - (duration / 2);
previewStartTime = Math.max(0, Math.min(previewStartTime, audioDuration - duration));
setHoverPreviewTime(previewStartTime);
};
const handleCanvasMouseLeave = () => {
setHoverPreviewTime(null);
};
const stopPlayback = () => {
sourceRef.current?.stop();
setIsPlaying(false);
setPlayingSegment(null);
setPlaybackPosition(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
// Animation loop for playback cursor
useEffect(() => {
if (!isPlaying || !audioContextRef.current) {
return;
}
const animate = () => {
if (!audioContextRef.current || !isPlaying) return;
const elapsed = audioContextRef.current.currentTime - playbackStartTimeRef.current;
const currentPos = playbackOffsetRef.current + elapsed;
setPlaybackPosition(currentPos);
animationFrameRef.current = requestAnimationFrame(animate);
};
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isPlaying]);
const handlePlaySegment = (segmentIndex: number) => {
if (!audioBuffer || !audioContextRef.current) return;
stopPlayback();
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
// Calculate segment start and duration
const segmentStart = startTime + (segmentIndex > 0 ? unlockSteps[segmentIndex - 1] : 0);
const segmentDuration = unlockSteps[segmentIndex] - (segmentIndex > 0 ? unlockSteps[segmentIndex - 1] : 0);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = segmentStart;
source.start(0, segmentStart, segmentDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(segmentIndex);
setPlaybackPosition(segmentStart);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setPlaybackPosition(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
};
const handlePlayFull = () => {
if (!audioBuffer || !audioContextRef.current) return;
if (isPlaying) {
stopPlayback();
} else {
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = startTime;
source.start(0, startTime, duration);
sourceRef.current = source;
setIsPlaying(true);
setPlaybackPosition(startTime);
source.onended = () => {
setIsPlaying(false);
setPlaybackPosition(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}
};
const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10));
const handleZoomOut = () => setZoom(prev => Math.max(prev / 1.5, 1));
const handlePanLeft = () => {
const visibleDuration = audioDuration / zoom;
setViewOffset(prev => Math.max(0, prev - visibleDuration * 0.2));
};
const handlePanRight = () => {
const visibleDuration = audioDuration / zoom;
setViewOffset(prev => Math.min(audioDuration - visibleDuration, prev + visibleDuration * 0.2));
};
return (
<div style={{ marginTop: '1rem' }}>
{/* Zoom and Pan Controls */}
<div style={{ marginBottom: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={handleZoomOut}
disabled={zoom <= 1}
style={{
padding: '0.25rem 0.5rem',
background: zoom <= 1 ? '#e5e7eb' : '#4f46e5',
color: zoom <= 1 ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: zoom <= 1 ? 'not-allowed' : 'pointer',
fontSize: '0.875rem'
}}
>
🔍
</button>
<button
onClick={handleZoomIn}
disabled={zoom >= 10}
style={{
padding: '0.25rem 0.5rem',
background: zoom >= 10 ? '#e5e7eb' : '#4f46e5',
color: zoom >= 10 ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: zoom >= 10 ? 'not-allowed' : 'pointer',
fontSize: '0.875rem'
}}
>
🔍+
</button>
<span style={{ fontSize: '0.75rem', color: '#666' }}>Zoom: {zoom.toFixed(1)}x</span>
{zoom > 1 && (
<>
<button onClick={handlePanLeft} style={{ padding: '0.25rem 0.5rem', background: '#4f46e5', color: 'white', border: 'none', borderRadius: '0.25rem', cursor: 'pointer', fontSize: '0.875rem' }}>
</button>
<button onClick={handlePanRight} style={{ padding: '0.25rem 0.5rem', background: '#4f46e5', color: 'white', border: 'none', borderRadius: '0.25rem', cursor: 'pointer', fontSize: '0.875rem' }}>
</button>
</>
)}
</div>
<canvas
ref={canvasRef}
width={800}
height={150}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
style={{
width: '100%',
height: 'auto',
cursor: 'pointer',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem'
}}
/>
{/* Playback Controls */}
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button
onClick={handlePlayFull}
style={{
padding: '0.5rem 1rem',
background: '#4f46e5',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
{isPlaying && playingSegment === null ? '⏸ Pause' : '▶ Play Full Selection'}
</button>
<div style={{ fontSize: '0.875rem', color: '#666' }}>
Start: {startTime}s | Duration: {duration}s | Total: {Math.floor(audioDuration)}s
</div>
</div>
{/* Segment Playback Buttons */}
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.875rem', color: '#666', marginRight: '0.5rem' }}>Play Segments:</span>
{unlockSteps.map((step, index) => {
const segmentStart = index > 0 ? unlockSteps[index - 1] : 0;
const segmentDuration = step - segmentStart;
return (
<button
key={index}
onClick={() => handlePlaySegment(index)}
style={{
padding: '0.25rem 0.75rem',
background: playingSegment === index ? '#ef4444' : '#f3f4f6',
color: playingSegment === index ? 'white' : '#374151',
border: '1px solid #e5e7eb',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: playingSegment === index ? 'bold' : 'normal'
}}
>
{index + 1} ({segmentDuration}s)
</button>
);
})}
</div>
</div>
);
}

View File

@@ -10,7 +10,7 @@ services:
- "3010:3000"
environment:
- DATABASE_URL=file:/app/data/prod.db
- ADMIN_PASSWORD=admin123 # Change this!
- ADMIN_PASSWORD=$$2b$$10$$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq # Change this! Must be a bcrypt hash. Escape $ as $$ in docker-compose!
- TZ=Europe/Berlin # Timezone for daily puzzle rotation
- GOTIFY_URL=https://gotify.example.com
- GOTIFY_APP_TOKEN=your_gotify_token
@@ -18,6 +18,12 @@ services:
volumes:
- ./data:/app/data
- ./public/uploads:/app/public/uploads
# Initialize DB if needed and run migration
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:3000/api/daily" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# 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);
}

18
lib/config.ts Normal file
View File

@@ -0,0 +1,18 @@
export const config = {
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
colors: {
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
},
credits: {
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
}
};

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,
@@ -96,13 +96,29 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
if (!dailyPuzzle) return null;
// Calculate puzzle number (sequential day count)
const whereClause = genreId
? { genreId: genreId }
: { genreId: null, specialId: null };
const puzzleCount = await prisma.dailyPuzzle.count({
where: {
...whereClause,
date: {
lte: dailyPuzzle.date
}
}
});
return {
id: dailyPuzzle.id,
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
puzzleNumber: puzzleCount,
audioUrl: `/api/audio/${dailyPuzzle.song.filename}`,
songId: dailyPuzzle.songId,
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
genre: genreName
};
@@ -131,32 +147,36 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
});
if (!dailyPuzzle) {
// Get songs available for this special
const allSongs = await prisma.song.findMany({
where: { specials: { some: { id: special.id } } },
// Get songs available for this special through SpecialSong
const specialSongs = await prisma.specialSong.findMany({
where: { specialId: special.id },
include: {
puzzles: {
where: { specialId: special.id }
},
},
song: {
include: {
puzzles: {
where: { specialId: special.id }
}
}
}
}
});
if (allSongs.length === 0) return null;
if (specialSongs.length === 0) return null;
// Calculate weights
const weightedSongs = allSongs.map(song => ({
song,
weight: 1.0 / (song.puzzles.length + 1),
const weightedSongs = specialSongs.map(specialSong => ({
specialSong,
weight: 1.0 / (specialSong.song.puzzles.length + 1),
}));
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
let selectedSong = weightedSongs[0].song;
let selectedSpecialSong = weightedSongs[0].specialSong;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
selectedSong = item.song;
selectedSpecialSong = item.specialSong;
break;
}
}
@@ -165,7 +185,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
dailyPuzzle = await prisma.dailyPuzzle.create({
data: {
date: today,
songId: selectedSong.id,
songId: selectedSpecialSong.songId,
specialId: special.id
},
include: { song: true },
@@ -183,16 +203,39 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
if (!dailyPuzzle) return null;
// Fetch the startTime from SpecialSong
const specialSong = await prisma.specialSong.findUnique({
where: {
specialId_songId: {
specialId: special.id,
songId: dailyPuzzle.songId
}
}
});
// Calculate puzzle number
const puzzleCount = await prisma.dailyPuzzle.count({
where: {
specialId: special.id,
date: {
lte: dailyPuzzle.date
}
}
});
return {
id: dailyPuzzle.id,
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
puzzleNumber: puzzleCount,
audioUrl: `/api/audio/${dailyPuzzle.song.filename}`,
songId: dailyPuzzle.songId,
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)
unlockSteps: JSON.parse(special.unlockSteps),
startTime: specialSong?.startTime || 0
};
} catch (error) {

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' https://plausible.elpatron.me", // 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 https://plausible.elpatron.me",
"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',
@@ -27,7 +28,7 @@ const nextConfig: NextConfig = {
},
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
value: 'public, max-age=3600, must-revalidate',
},
],
},

1240
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.0",
"version": "0.1.0.14",
"private": true,
"scripts": {
"dev": "next dev",
@@ -10,13 +10,17 @@
},
"dependencies": {
"@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"music-metadata": "^11.10.2",
"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",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

BIN
prisma/dev.db.bak Normal file

Binary file not shown.

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Special" ADD COLUMN "curator" TEXT;

View File

@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "SpecialSong" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"specialId" INTEGER NOT NULL,
"songId" INTEGER NOT NULL,
"startTime" INTEGER NOT NULL DEFAULT 0,
"order" INTEGER,
CONSTRAINT "SpecialSong_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SpecialSong_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Migrate data from _SongToSpecial to SpecialSong
INSERT INTO "SpecialSong" ("specialId", "songId", "startTime")
SELECT "B" as "specialId", "A" as "songId", 0 as "startTime"
FROM "_SongToSpecial";
-- DropTable
DROP TABLE "_SongToSpecial";
-- CreateIndex
CREATE UNIQUE INDEX "SpecialSong_specialId_songId_key" ON "SpecialSong"("specialId", "songId");

View File

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

View File

@@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE "Special" ADD COLUMN "curator" TEXT;
-- 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,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"averageRating" REAL NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0
);
INSERT INTO "new_Song" ("artist", "coverImage", "createdAt", "filename", "id", "title") SELECT "artist", "coverImage", "createdAt", "filename", "id", "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,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,34 +16,57 @@ 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 Special[]
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[]
}
model Special {
id Int @id @default(autoincrement())
name String @unique
maxAttempts Int @default(7)
unlockSteps String // JSON array: "[2,4,7,11,16,30,60]"
createdAt DateTime @default(now())
songs Song[]
dailyPuzzles DailyPuzzle[]
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())
launchDate DateTime?
endDate DateTime?
curator String?
songs SpecialSong[]
puzzles DailyPuzzle[]
news News[]
}
model SpecialSong {
id Int @id @default(autoincrement())
specialId Int
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
songId Int
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
startTime Int @default(0) // Start time in seconds
order Int? // For manual ordering
@@unique([specialId, songId])
}
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?
@@ -51,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."

72
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
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
# Quick restart with pre-built image
echo "🔄 Restarting with new image (minimal downtime)..."
docker compose up -d
# Clean up old images
echo "🧹 Cleaning up old images..."
docker image prune -f
echo "✅ Deployment complete!"
echo ""
echo "📊 Showing logs (Ctrl+C to exit)..."
docker compose logs -f

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

24
scripts/hash-password.js Normal file
View File

@@ -0,0 +1,24 @@
const bcrypt = require('bcryptjs');
const password = process.argv[2];
if (!password) {
console.error('Please provide a password to hash.');
console.error('Usage: node scripts/hash-password.js <password>');
process.exit(1);
}
const saltRounds = 10;
bcrypt.hash(password, saltRounds, (err, hash) => {
if (err) {
console.error('Error hashing password:', err);
return;
}
console.log('Plaintext:', password);
console.log('Bcrypt Hash:', hash);
console.log('\n⚠ IMPORTANT FOR DOCKER COMPOSE:');
console.log('If you use this hash directly in docker-compose.yml, you MUST escape the $ signs:');
console.log('Docker Hash:', hash.replace(/\$/g, '$$$$'));
console.log('\nSet this hash as your ADMIN_PASSWORD environment variable.');
});

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

@@ -33,23 +33,33 @@ async function restoreSongs() {
const title = metadata.common.title || 'Unknown Title';
const artist = metadata.common.artist || 'Unknown Artist';
const genres = metadata.common.genre || [];
// Try to find matching cover
// This is a best-effort guess based on timestamp or just null if we can't link it easily
// Since we don't store the link between file and cover in filename, we might lose cover association
// unless we re-extract it. But we already have cover files.
// For now, let's just restore the song entry. Re-extracting cover would duplicate files.
// If the user wants covers back perfectly, we might need to re-parse or just leave null.
// Let's leave null for now to avoid clutter, or maybe try to find a cover with similar timestamp if possible?
// Actually, the cover filename is not easily deducible from song filename.
// Let's just restore the song data.
// Create or find genres
const genreConnect = [];
for (const genreName of genres) {
if (!genreName) continue;
// Simple normalization
const normalizedGenre = genreName.trim();
// Upsert genre (we can't use upsert easily with connect, so find or create first)
let genre = await prisma.genre.findUnique({ where: { name: normalizedGenre } });
if (!genre) {
genre = await prisma.genre.create({ data: { name: normalizedGenre } });
console.log(`Created genre: ${normalizedGenre}`);
}
genreConnect.push({ id: genre.id });
}
await prisma.song.create({
data: {
title,
artist,
filename,
// coverImage: null // We lose the cover link unfortunately, unless we re-extract
genres: {
connect: genreConnect
}
}
});

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();