Compare commits
111 Commits
e56d7893d7
...
v0.1.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fb296564 | ||
|
|
301dce4c97 | ||
|
|
b66bab48bd | ||
|
|
fea8384e60 | ||
|
|
de8813da3e | ||
|
|
0877842107 | ||
|
|
a5cbbffc20 | ||
|
|
ffb7be602f | ||
|
|
1d62aca2fb | ||
|
|
9bf7e72a6c | ||
|
|
f8b5dcf300 | ||
|
|
072158f4ed | ||
|
|
898d2f5959 | ||
|
|
a7aec80f39 | ||
|
|
0e313db2e3 | ||
|
|
3e647cd44b | ||
|
|
54af256e91 | ||
|
|
ce413cf6bc | ||
|
|
5102ca86cb | ||
|
|
eb3d2c86d7 | ||
|
|
883875b82a | ||
|
|
4c13817e77 | ||
|
|
35fe5f2d44 | ||
|
|
70501d626b | ||
|
|
41ce6c12ce | ||
|
|
a744393335 | ||
|
|
0ee3a48770 | ||
|
|
187774bce7 | ||
|
|
67cf85dc22 | ||
|
|
326023a705 | ||
|
|
41e2ec1495 | ||
|
|
62402d7000 | ||
|
|
0599c066d9 | ||
|
|
f7de7f2684 | ||
|
|
e5d06029ef | ||
|
|
e8e0aa27fb | ||
|
|
7f455053e7 | ||
|
|
3309b5c5ee | ||
|
|
cd30476349 | ||
|
|
cd19a6c04d | ||
|
|
7011a24b46 | ||
|
|
9a98830245 | ||
|
|
3630745169 | ||
|
|
831adcaf17 | ||
|
|
2d6481a42f | ||
|
|
0f7d66c619 | ||
|
|
bc36a09b81 | ||
|
|
a71afa4f6f | ||
|
|
ceae0266b8 | ||
|
|
27ed9eedb2 | ||
|
|
015862ce0c | ||
|
|
4d807c77d0 | ||
|
|
7b975dc3e3 | ||
|
|
e5b0512884 | ||
|
|
e9a8c41a7d | ||
|
|
d280106336 | ||
|
|
d75910ecc5 | ||
|
|
d09dbece5f | ||
|
|
80e6066c17 | ||
|
|
b8321cef56 | ||
|
|
57affff7d1 | ||
|
|
f13a719d0e | ||
|
|
4d3032df36 | ||
|
|
dc83c8372f | ||
|
|
7795168b16 | ||
|
|
8262a96213 | ||
|
|
b294a3a8e6 | ||
|
|
2a38bce02c | ||
|
|
6fd5f8ed0c | ||
|
|
c05ead4493 | ||
|
|
5fb450d37e | ||
|
|
e9526918e1 | ||
|
|
69eb69b8cd | ||
|
|
4bb0aed323 | ||
|
|
fb911ccf4c | ||
|
|
ec885212a5 | ||
|
|
54f47a9470 | ||
|
|
e06e0d2919 | ||
|
|
23c2697424 | ||
|
|
86829af17d | ||
|
|
b27d5e49c9 | ||
|
|
5944c14614 | ||
|
|
587fa59b79 | ||
|
|
4f088305df | ||
|
|
e24588f3ee | ||
|
|
4b9e7ac9ec | ||
|
|
77a769fb91 | ||
|
|
291fc2037c | ||
|
|
fdc5594d69 | ||
|
|
aff752d4cb | ||
|
|
23e145e05f | ||
|
|
a6cfa833e3 | ||
|
|
cf43adf63b | ||
|
|
58e9b4fa60 | ||
|
|
328c8fe98a | ||
|
|
5d5a75a735 | ||
|
|
7fc1c2c201 | ||
|
|
6086e1903c | ||
|
|
21b53a692b | ||
|
|
9a072061a1 | ||
|
|
c612da6371 | ||
|
|
fdd5463391 | ||
|
|
ae9e4c504e | ||
|
|
903d626699 | ||
|
|
c270f2098f | ||
|
|
7d117d3bd4 | ||
|
|
f8a495920c | ||
|
|
10cab22cfe | ||
|
|
15746f404a | ||
|
|
8c720e287f | ||
|
|
dc69fd1498 |
47
.agent/plans/add_subtitles.md
Normal file
47
.agent/plans/add_subtitles.md
Normal 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
2
.gitignore
vendored
@@ -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
185
DEBUG_VERSION.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Debug Version Display - Remote Server Checklist
|
||||
|
||||
## 1. Überprüfe Git-Tags auf dem Remote-Server
|
||||
|
||||
```bash
|
||||
# Im Projekt-Verzeichnis auf dem Remote-Server
|
||||
cd /path/to/hoerdle
|
||||
|
||||
# Zeige alle Tags
|
||||
git tag -l
|
||||
|
||||
# Zeige aktuellen Tag/Version
|
||||
git describe --tags --always
|
||||
|
||||
# Wenn keine Tags angezeigt werden:
|
||||
git fetch --tags
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Sollte `v0.1.0.2` oder ähnlich zeigen
|
||||
|
||||
---
|
||||
|
||||
## 2. Überprüfe die version.txt im Container
|
||||
|
||||
```bash
|
||||
# Zeige den Inhalt der Version-Datei im laufenden Container
|
||||
docker exec hoerdle cat /app/version.txt
|
||||
|
||||
# Sollte die Version zeigen, z.B. "v0.1.0.2"
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Die aktuelle Version, nicht "unknown" oder "dev"
|
||||
|
||||
---
|
||||
|
||||
## 3. Überprüfe die Umgebungsvariable im Container
|
||||
|
||||
```bash
|
||||
# Zeige alle Umgebungsvariablen
|
||||
docker exec hoerdle env | grep APP_VERSION
|
||||
|
||||
# Sollte APP_VERSION=v0.1.0.2 oder ähnlich zeigen
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** `APP_VERSION=v0.1.0.2`
|
||||
|
||||
---
|
||||
|
||||
## 4. Überprüfe die Container-Logs beim Start
|
||||
|
||||
```bash
|
||||
# Zeige die letzten Logs beim Container-Start
|
||||
docker logs hoerdle | head -20
|
||||
|
||||
# Suche speziell nach Version-Ausgaben
|
||||
docker logs hoerdle | grep -i version
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Eine Zeile wie "App version: v0.1.0.2"
|
||||
|
||||
---
|
||||
|
||||
## 5. Teste die API direkt
|
||||
|
||||
```bash
|
||||
# Rufe die Version-API auf
|
||||
curl http://localhost:3010/api/version
|
||||
|
||||
# Sollte JSON zurückgeben: {"version":"v0.1.0.2"}
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** `{"version":"v0.1.0.2"}`
|
||||
|
||||
---
|
||||
|
||||
## 6. Überprüfe wann der Container gebaut wurde
|
||||
|
||||
```bash
|
||||
# Zeige Image-Informationen
|
||||
docker images | grep hoerdle
|
||||
|
||||
# Zeige detaillierte Container-Informationen
|
||||
docker inspect hoerdle | grep -i created
|
||||
```
|
||||
|
||||
**Wichtig:** Wenn das Image vor deinem letzten Deployment erstellt wurde, wurde es noch nicht neu gebaut!
|
||||
|
||||
---
|
||||
|
||||
## 7. Überprüfe Build-Logs
|
||||
|
||||
```bash
|
||||
# Baue das Image neu und beobachte die Ausgabe
|
||||
docker compose build --no-cache 2>&1 | tee build.log
|
||||
|
||||
# Suche nach der Version-Ausgabe im Build
|
||||
grep -i "Building version" build.log
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Eine Zeile wie "Building version: v0.1.0.2"
|
||||
|
||||
---
|
||||
|
||||
## Häufige Probleme und Lösungen
|
||||
|
||||
### Problem 1: Tags nicht auf dem Server
|
||||
```bash
|
||||
git fetch --tags
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
### Problem 2: Container wurde nicht neu gebaut
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Problem 3: Alte version.txt im Container
|
||||
```bash
|
||||
# Stoppe Container, lösche Image, baue neu
|
||||
docker compose down
|
||||
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Problem 4: .git Verzeichnis nicht im Build-Context
|
||||
```bash
|
||||
# Überprüfe ob .git existiert
|
||||
ls -la .git
|
||||
|
||||
# Überprüfe .dockerignore (sollte .git NICHT ausschließen)
|
||||
cat .dockerignore 2>/dev/null || echo "Keine .dockerignore Datei"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vollständiger Neustart (wenn nichts anderes hilft)
|
||||
|
||||
```bash
|
||||
# 1. Stoppe alles
|
||||
docker compose down
|
||||
|
||||
# 2. Lösche alte Images
|
||||
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||
|
||||
# 3. Hole neueste Änderungen und Tags
|
||||
git pull
|
||||
git fetch --tags
|
||||
|
||||
# 4. Überprüfe Version lokal
|
||||
git describe --tags --always
|
||||
|
||||
# 5. Baue komplett neu
|
||||
docker compose build --no-cache
|
||||
|
||||
# 6. Starte Container
|
||||
docker compose up -d
|
||||
|
||||
# 7. Überprüfe Logs
|
||||
docker logs hoerdle | grep -i version
|
||||
|
||||
# 8. Teste API
|
||||
curl http://localhost:3010/api/version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging-Befehl für alle Checks auf einmal
|
||||
|
||||
```bash
|
||||
echo "=== Git Tags ===" && \
|
||||
git describe --tags --always && \
|
||||
echo -e "\n=== version.txt im Container ===" && \
|
||||
docker exec hoerdle cat /app/version.txt 2>/dev/null || echo "Container läuft nicht oder Datei fehlt" && \
|
||||
echo -e "\n=== APP_VERSION Env ===" && \
|
||||
docker exec hoerdle env | grep APP_VERSION || echo "Variable nicht gesetzt" && \
|
||||
echo -e "\n=== API Response ===" && \
|
||||
curl -s http://localhost:3010/api/version && \
|
||||
echo -e "\n\n=== Container Created ===" && \
|
||||
docker inspect hoerdle | grep -i created | head -1
|
||||
```
|
||||
|
||||
Kopiere diesen Befehl und führe ihn auf dem Remote-Server aus. Schicke mir die Ausgabe!
|
||||
84
DEPLOYMENT.md
Normal file
84
DEPLOYMENT.md
Normal 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.
|
||||
28
Dockerfile
28
Dockerfile
@@ -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
|
||||
@@ -63,8 +80,7 @@ ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# Start command: migrate DB and start server
|
||||
# Note: In production, migrations should ideally be run in a separate step or init container,
|
||||
# but for simplicity with SQLite we can run push here or assume the volume has the DB.
|
||||
# We'll use a custom start script or just run server, assuming user handles migration or we use prisma db push on start.
|
||||
# Let's use a simple entrypoint script to ensure DB exists.
|
||||
CMD ["node", "server.js"]
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/docker-entrypoint.sh ./scripts/docker-entrypoint.sh
|
||||
RUN chmod +x ./scripts/docker-entrypoint.sh
|
||||
|
||||
CMD ["./scripts/docker-entrypoint.sh"]
|
||||
|
||||
173
README.md
173
README.md
@@ -8,18 +8,61 @@ 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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -64,10 +107,14 @@ 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)
|
||||
|
||||
2. **Starten:**
|
||||
```bash
|
||||
@@ -83,7 +130,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)
|
||||
|
||||
@@ -117,6 +181,92 @@ server {
|
||||
|
||||
Eine vollständige Beispiel-Konfiguration findest du in `nginx.conf.example`.
|
||||
|
||||
## iFrame-Einbindung
|
||||
|
||||
Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die App ist responsive und passt sich automatisch an die iFrame-Größe an.
|
||||
|
||||
### Grundlegende Einbindung
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
allow="autoplay"
|
||||
title="Hördle - Daily Music Quiz">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
### Genre-spezifische Einbindung
|
||||
|
||||
Einzelne Genres können direkt eingebunden werden:
|
||||
|
||||
```html
|
||||
<!-- Rock Genre -->
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/Rock"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
allow="autoplay"
|
||||
title="Hördle Rock Quiz">
|
||||
</iframe>
|
||||
|
||||
<!-- Pop Genre -->
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/Pop"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
allow="autoplay"
|
||||
title="Hördle Pop Quiz">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
### Special-Einbindung
|
||||
|
||||
Auch thematische Specials können direkt eingebettet werden:
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
allow="autoplay"
|
||||
title="Hördle Weihnachts-Special">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
### Empfohlene Einstellungen
|
||||
|
||||
- **Mindesthöhe:** 800px (damit alle Elemente sichtbar sind)
|
||||
- **Breite:** 100% oder mindestens 600px
|
||||
- **`allow="autoplay"`:** Erforderlich für Audio-Wiedergabe
|
||||
- **Responsive:** Die App passt sich automatisch an mobile Geräte an
|
||||
|
||||
### Beispiel mit responsiver Höhe
|
||||
|
||||
```html
|
||||
<div style="position: relative; padding-bottom: 133%; height: 0; overflow: hidden;">
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||
frameborder="0"
|
||||
allow="autoplay"
|
||||
title="Hördle">
|
||||
</iframe>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Hinweise
|
||||
|
||||
- Der Spielfortschritt wird im LocalStorage des iFrames gespeichert
|
||||
- Nutzer können innerhalb des iFrames zwischen Genres wechseln (Navigation bleibt erhalten)
|
||||
- Die Teilen-Funktion funktioniert auch im iFrame
|
||||
- Für beste Performance sollte der iFrame auf derselben Domain wie die Hauptseite gehostet werden (vermeidet CORS-Probleme)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Audio-Dateien lassen sich nicht abspielen (in Produktion mit Nginx)
|
||||
@@ -134,6 +284,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
|
||||
|
||||
110
app/[genre]/page.tsx
Normal file
110
app/[genre]/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import Game from '@/components/Game';
|
||||
import NewsSection from '@/components/NewsSection';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ genre: string }>;
|
||||
}
|
||||
|
||||
export default async function GenrePage({ params }: PageProps) {
|
||||
const { genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
// Check if genre exists and is active
|
||||
const currentGenre = await prisma.genre.findUnique({
|
||||
where: { name: decodedGenre }
|
||||
});
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const now = new Date();
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
||||
const isEnded = s.endDate && s.endDate < now;
|
||||
return isStarted && !isEnded;
|
||||
});
|
||||
|
||||
const upcomingSpecials = specials.filter(s => {
|
||||
return s.launchDate && s.launchDate > now;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${g.name}`}
|
||||
style={{
|
||||
fontWeight: g.name === decodedGenre ? 'bold' : 'normal',
|
||||
textDecoration: g.name === decodedGenre ? 'underline' : 'none',
|
||||
color: g.name === decodedGenre ? 'black' : '#4b5563'
|
||||
}}
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Specials */}
|
||||
{activeSpecials.map(s => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/special/${s.name}`}
|
||||
style={{
|
||||
color: '#be185d', // Pink-700
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Specials */}
|
||||
{upcomingSpecials.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
||||
Coming soon: {upcomingSpecials.map(s => (
|
||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||
★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: process.env.TZ
|
||||
}) : ''})
|
||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NewsSection />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +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) {
|
||||
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null, score?: number) {
|
||||
try {
|
||||
const title = `Hördle #${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
|
||||
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} was solved in ${attempts} attempt(s).`
|
||||
: `Puzzle #${puzzleId} 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',
|
||||
@@ -29,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' };
|
||||
}
|
||||
}
|
||||
|
||||
1787
app/admin/page.tsx
1787
app/admin/page.tsx
File diff suppressed because it is too large
Load Diff
232
app/admin/specials/[id]/page.tsx
Normal file
232
app/admin/specials/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
app/api/admin/daily-puzzles/route.ts
Normal file
123
app/api/admin/daily-puzzles/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const dailyPuzzles = await prisma.dailyPuzzle.findMany({
|
||||
where: {
|
||||
date: today
|
||||
},
|
||||
include: {
|
||||
song: true,
|
||||
genre: true,
|
||||
special: true
|
||||
},
|
||||
orderBy: [
|
||||
{ genreId: 'asc' },
|
||||
{ specialId: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
const formattedPuzzles = dailyPuzzles.map(puzzle => ({
|
||||
id: puzzle.id,
|
||||
date: puzzle.date,
|
||||
category: puzzle.specialId
|
||||
? `★ ${puzzle.special?.name}`
|
||||
: puzzle.genreId
|
||||
? `🏷️ ${puzzle.genre?.name}`
|
||||
: '🌍 Global',
|
||||
categoryType: puzzle.specialId ? 'special' : puzzle.genreId ? 'genre' : 'global',
|
||||
genreId: puzzle.genreId,
|
||||
specialId: puzzle.specialId,
|
||||
song: {
|
||||
id: puzzle.song.id,
|
||||
title: puzzle.song.title,
|
||||
artist: puzzle.song.artist,
|
||||
filename: puzzle.song.filename,
|
||||
audioUrl: `/api/audio/${puzzle.song.filename}`
|
||||
}
|
||||
}));
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!puzzleId) {
|
||||
return NextResponse.json({ error: 'Missing puzzleId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get puzzle details before deletion
|
||||
const puzzle = await prisma.dailyPuzzle.findUnique({
|
||||
where: { id: Number(puzzleId) }
|
||||
});
|
||||
|
||||
if (!puzzle) {
|
||||
return NextResponse.json({ error: 'Puzzle not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete the puzzle
|
||||
await prisma.dailyPuzzle.delete({
|
||||
where: { id: Number(puzzleId) }
|
||||
});
|
||||
|
||||
// Regenerate puzzle based on type
|
||||
const { getOrCreateDailyPuzzle, getOrCreateSpecialPuzzle } = await import('@/lib/dailyPuzzle');
|
||||
|
||||
let newPuzzle;
|
||||
if (puzzle.specialId) {
|
||||
const special = await prisma.special.findUnique({
|
||||
where: { id: puzzle.specialId }
|
||||
});
|
||||
if (special) {
|
||||
newPuzzle = await getOrCreateSpecialPuzzle(special.name);
|
||||
}
|
||||
} else if (puzzle.genreId) {
|
||||
const genre = await prisma.genre.findUnique({
|
||||
where: { id: puzzle.genreId }
|
||||
});
|
||||
if (genre) {
|
||||
newPuzzle = await getOrCreateDailyPuzzle(genre.name);
|
||||
}
|
||||
} else {
|
||||
newPuzzle = await getOrCreateDailyPuzzle(null);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Puzzle deleted and regenerated',
|
||||
newPuzzle
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting puzzle:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
95
app/api/admin/rebuild/route.ts
Normal file
95
app/api/admin/rebuild/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { parseFile } from 'music-metadata';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
console.log('[Rebuild] Starting database rebuild...');
|
||||
|
||||
// 1. Clear Database
|
||||
// Delete in order to respect foreign keys
|
||||
await prisma.dailyPuzzle.deleteMany();
|
||||
// We need to clear the many-to-many relations first implicitly by deleting songs/genres/specials
|
||||
// But explicit deletion of join tables isn't needed with Prisma's cascading deletes usually,
|
||||
// but let's be safe and delete main entities.
|
||||
await prisma.song.deleteMany();
|
||||
await prisma.genre.deleteMany();
|
||||
await prisma.special.deleteMany();
|
||||
|
||||
console.log('[Rebuild] Database cleared.');
|
||||
|
||||
// 2. Clear Covers Directory
|
||||
const coversDir = path.join(process.cwd(), 'public/uploads/covers');
|
||||
try {
|
||||
const coverFiles = await fs.readdir(coversDir);
|
||||
for (const file of coverFiles) {
|
||||
if (file !== '.gitkeep') { // Preserve .gitkeep if it exists
|
||||
await fs.unlink(path.join(coversDir, file));
|
||||
}
|
||||
}
|
||||
console.log('[Rebuild] Covers directory cleared.');
|
||||
} catch (e) {
|
||||
console.log('[Rebuild] Covers directory might not exist or empty, creating it.');
|
||||
await fs.mkdir(coversDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 3. Re-import Songs
|
||||
const uploadsDir = path.join(process.cwd(), 'public/uploads');
|
||||
const files = await fs.readdir(uploadsDir);
|
||||
const mp3Files = files.filter(f => f.endsWith('.mp3'));
|
||||
|
||||
console.log(`[Rebuild] Found ${mp3Files.length} MP3 files to import.`);
|
||||
|
||||
let importedCount = 0;
|
||||
|
||||
for (const filename of mp3Files) {
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
|
||||
try {
|
||||
const metadata = await parseFile(filePath);
|
||||
|
||||
const title = metadata.common.title || 'Unknown Title';
|
||||
const artist = metadata.common.artist || 'Unknown Artist';
|
||||
|
||||
let coverImage = null;
|
||||
const picture = metadata.common.picture?.[0];
|
||||
|
||||
if (picture) {
|
||||
const extension = picture.format.split('/')[1] || 'jpg';
|
||||
const coverFilename = `cover-${Date.now()}-${Math.random().toString(36).substring(7)}.${extension}`;
|
||||
const coverPath = path.join(coversDir, coverFilename);
|
||||
|
||||
await fs.writeFile(coverPath, picture.data);
|
||||
coverImage = coverFilename;
|
||||
}
|
||||
|
||||
await prisma.song.create({
|
||||
data: {
|
||||
title,
|
||||
artist,
|
||||
filename,
|
||||
coverImage
|
||||
}
|
||||
});
|
||||
importedCount++;
|
||||
} catch (e) {
|
||||
console.error(`[Rebuild] Failed to process ${filename}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Rebuild] Successfully imported ${importedCount} songs.`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Database rebuilt. Imported ${importedCount} songs.`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Rebuild] Error:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
91
app/api/audio/[filename]/route.ts
Normal file
91
app/api/audio/[filename]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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) {
|
||||
stream.on('data', (chunk: any) => controller.enqueue(chunk));
|
||||
stream.on('end', () => controller.close());
|
||||
stream.on('error', (err: any) => controller.error(err));
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
stream.on('data', (chunk: any) => controller.enqueue(chunk));
|
||||
stream.on('end', () => controller.close());
|
||||
stream.on('error', (err: any) => controller.error(err));
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
193
app/api/categorize/route.ts
Normal file
193
app/api/categorize/route.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
'use server';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||
const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku';
|
||||
const BATCH_SIZE = 20; // Process 20 songs at a time to avoid timeouts
|
||||
|
||||
interface CategorizeResult {
|
||||
songId: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
assignedGenres: string[];
|
||||
}
|
||||
|
||||
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(
|
||||
{ error: 'OPENROUTER_API_KEY not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get offset from request body (for batch processing)
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const offset = body.offset || 0;
|
||||
|
||||
// Fetch all songs without genres
|
||||
const totalUncategorized = await prisma.song.count({
|
||||
where: {
|
||||
genres: {
|
||||
none: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (totalUncategorized === 0) {
|
||||
return Response.json({
|
||||
message: 'No uncategorized songs found',
|
||||
results: [],
|
||||
hasMore: false,
|
||||
totalUncategorized: 0,
|
||||
processed: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch batch of songs
|
||||
const uncategorizedSongs = await prisma.song.findMany({
|
||||
where: {
|
||||
genres: {
|
||||
none: {}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
genres: true
|
||||
},
|
||||
take: BATCH_SIZE,
|
||||
skip: offset
|
||||
});
|
||||
|
||||
// Fetch all available genres
|
||||
const allGenres = await prisma.genre.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
if (allGenres.length === 0) {
|
||||
return Response.json(
|
||||
{ error: 'No genres available. Please create genres first.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const results: CategorizeResult[] = [];
|
||||
|
||||
// Process each song in this batch
|
||||
for (const song of uncategorizedSongs) {
|
||||
try {
|
||||
const genreNames = allGenres.map(g => g.name);
|
||||
|
||||
const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres.
|
||||
|
||||
Song: "${song.title}" by ${song.artist}
|
||||
Available genres: ${genreNames.join(', ')}
|
||||
|
||||
Rules:
|
||||
- Select 0-3 genres that best match this song
|
||||
- Only use genres from the available list
|
||||
- Respond with ONLY a JSON array of genre names, nothing else
|
||||
- If no genres match well, return an empty array []
|
||||
|
||||
Example response: ["Rock", "Alternative"]
|
||||
|
||||
Your response:`;
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://hoerdle.elpatron.me',
|
||||
'X-Title': 'Hördle Genre Categorization'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: OPENROUTER_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 100
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`OpenRouter API error for song ${song.id}:`, await response.text());
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const aiResponse = data.choices?.[0]?.message?.content?.trim() || '[]';
|
||||
|
||||
// Parse AI response
|
||||
let suggestedGenreNames: string[] = [];
|
||||
try {
|
||||
suggestedGenreNames = JSON.parse(aiResponse);
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse AI response for song ${song.id}:`, aiResponse);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter to only valid genres and get their IDs
|
||||
const genreIds = allGenres
|
||||
.filter(g => suggestedGenreNames.includes(g.name))
|
||||
.map(g => g.id)
|
||||
.slice(0, 3); // Max 3 genres
|
||||
|
||||
if (genreIds.length > 0) {
|
||||
// Update song with genres
|
||||
await prisma.song.update({
|
||||
where: { id: song.id },
|
||||
data: {
|
||||
genres: {
|
||||
connect: genreIds.map(id => ({ id }))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
results.push({
|
||||
songId: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
assignedGenres: suggestedGenreNames.filter(name =>
|
||||
allGenres.some(g => g.name === name)
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing song ${song.id}:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const newOffset = offset + BATCH_SIZE;
|
||||
const hasMore = newOffset < totalUncategorized;
|
||||
|
||||
return Response.json({
|
||||
message: `Processed ${uncategorizedSongs.length} songs in this batch, categorized ${results.length}`,
|
||||
totalUncategorized,
|
||||
processed: Math.min(newOffset, totalUncategorized),
|
||||
hasMore,
|
||||
nextOffset: hasMore ? newOffset : null,
|
||||
results
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Categorization error:', error);
|
||||
return Response.json(
|
||||
{ error: 'Failed to categorize songs' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getTodayISOString } from '@/lib/dateUtils';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const today = getTodayISOString();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const genreName = searchParams.get('genre');
|
||||
|
||||
let dailyPuzzle = await prisma.dailyPuzzle.findUnique({
|
||||
where: { date: today },
|
||||
include: { song: true },
|
||||
});
|
||||
const puzzle = await getOrCreateDailyPuzzle(genreName);
|
||||
|
||||
console.log(`[Daily Puzzle] Date: ${today}, Found existing: ${!!dailyPuzzle}`);
|
||||
|
||||
if (!dailyPuzzle) {
|
||||
// Get all songs with their usage count
|
||||
const allSongs = await prisma.song.findMany({
|
||||
include: {
|
||||
puzzles: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (allSongs.length === 0) {
|
||||
return NextResponse.json({ error: 'No songs available' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Calculate weights: songs never used get weight 1.0,
|
||||
// songs used once get 0.5, twice 0.33, etc.
|
||||
const weightedSongs = allSongs.map(song => ({
|
||||
song,
|
||||
weight: 1.0 / (song.puzzles.length + 1),
|
||||
}));
|
||||
|
||||
// Calculate total weight
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
|
||||
// Pick a random song based on weights
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSong = weightedSongs[0].song;
|
||||
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
selectedSong = item.song;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the daily puzzle
|
||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||
data: {
|
||||
date: today,
|
||||
songId: selectedSong.id,
|
||||
},
|
||||
include: { song: true },
|
||||
});
|
||||
|
||||
console.log(`[Daily Puzzle] Created new puzzle for ${today} with song: ${selectedSong.title} (ID: ${selectedSong.id})`);
|
||||
if (!puzzle) {
|
||||
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!dailyPuzzle) {
|
||||
return NextResponse.json({ error: 'Failed to create puzzle' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: dailyPuzzle.id,
|
||||
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
|
||||
songId: dailyPuzzle.songId,
|
||||
title: dailyPuzzle.song.title,
|
||||
artist: dailyPuzzle.song.artist,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
});
|
||||
return NextResponse.json(puzzle);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching daily puzzle:', error);
|
||||
|
||||
100
app/api/genres/route.ts
Normal file
100
app/api/genres/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const genres = await prisma.genre.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { songs: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
return NextResponse.json(genres);
|
||||
} catch (error) {
|
||||
console.error('Error fetching genres:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
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(),
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
active: active !== undefined ? active : true
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(genre);
|
||||
} catch (error) {
|
||||
console.error('Error creating genre:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.genre.delete({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting genre:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id, name, subtitle, active } = await request.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const genre = await prisma.genre.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(name && { name: name.trim() }),
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
...(active !== undefined && { active })
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(genre);
|
||||
} catch (error) {
|
||||
console.error('Error updating genre:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
146
app/api/news/route.ts
Normal file
146
app/api/news/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -3,18 +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: {
|
||||
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,
|
||||
@@ -22,24 +34,70 @@ export async function GET() {
|
||||
filename: song.filename,
|
||||
createdAt: song.createdAt,
|
||||
coverImage: song.coverImage,
|
||||
releaseYear: song.releaseYear,
|
||||
activations: song.puzzles.length,
|
||||
puzzles: song.puzzles,
|
||||
genres: song.genres,
|
||||
specials: song.specials.map(ss => ss.special),
|
||||
averageRating: song.averageRating,
|
||||
ratingCount: song.ratingCount,
|
||||
excludeFromGlobal: song.excludeFromGlobal,
|
||||
}));
|
||||
|
||||
return NextResponse.json(songsWithActivations);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
console.log('[UPLOAD] Starting song upload request');
|
||||
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
console.log('[UPLOAD] Authentication failed');
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[UPLOAD] Parsing form data...');
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
let title = '';
|
||||
let artist = '';
|
||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
|
||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
||||
|
||||
if (!file) {
|
||||
console.error('[UPLOAD] No file provided');
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Security: Validate file size (max 50MB)
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({
|
||||
error: `File too large. Maximum size is 50MB, got ${(file.size / 1024 / 1024).toFixed(2)}MB`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Security: Validate MIME type
|
||||
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3'];
|
||||
if (!allowedMimeTypes.includes(file.type)) {
|
||||
return NextResponse.json({
|
||||
error: `Invalid file type. Expected MP3, got ${file.type}`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Security: Validate file extension
|
||||
if (!file.name.toLowerCase().endsWith('.mp3')) {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid file extension. Only .mp3 files are allowed'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
|
||||
|
||||
// Validate and extract metadata from file
|
||||
let metadata;
|
||||
@@ -61,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
|
||||
@@ -103,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
|
||||
@@ -137,13 +225,29 @@ 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 }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -157,16 +261,79 @@ 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 } = 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 });
|
||||
}
|
||||
|
||||
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 }))
|
||||
};
|
||||
}
|
||||
|
||||
// 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: { title, artist },
|
||||
data,
|
||||
include: {
|
||||
genres: true,
|
||||
specials: {
|
||||
include: {
|
||||
special: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedSong);
|
||||
@@ -177,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();
|
||||
|
||||
|
||||
65
app/api/specials/[id]/route.ts
Normal file
65
app/api/specials/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
89
app/api/specials/[id]/songs/route.ts
Normal file
89
app/api/specials/[id]/songs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
77
app/api/specials/route.ts
Normal file
77
app/api/specials/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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) {
|
||||
// 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 });
|
||||
}
|
||||
await prisma.special.delete({ where: { id: Number(id) } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// 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 });
|
||||
}
|
||||
const updated = await prisma.special.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
subtitle: subtitle || null, // Allow clearing or setting
|
||||
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
|
||||
...(unlockSteps && { unlockSteps }),
|
||||
launchDate: launchDate ? new Date(launchDate) : null,
|
||||
endDate: endDate ? new Date(endDate) : null,
|
||||
curator: curator || null,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
65
app/api/version/route.ts
Normal file
65
app/api/version/route.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -278,12 +278,59 @@ body {
|
||||
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: #4b5563;
|
||||
color: #fff;
|
||||
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: #ef4444;
|
||||
color: #fff;
|
||||
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;
|
||||
@@ -363,4 +410,50 @@ body {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text {
|
||||
visibility: hidden;
|
||||
width: 200px;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -100px;
|
||||
margin-top: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
pointer-events: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-text {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent #333 transparent;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -24,6 +25,9 @@ export const viewport: Viewport = {
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -31,17 +35,18 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
data-domain="hoerdle.elpatron.me"
|
||||
src="https://plausible.elpatron.me/js/script.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<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>
|
||||
);
|
||||
|
||||
168
app/page.tsx
168
app/page.tsx
@@ -1,81 +1,107 @@
|
||||
import Game from '@/components/Game';
|
||||
import { getTodayISOString } from '@/lib/dateUtils';
|
||||
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';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// PrismaClient is attached to the `global` object in development to prevent
|
||||
// exhausting your database connection limit.
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||
|
||||
const prisma = globalForPrisma.prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
|
||||
async function getDailyPuzzle() {
|
||||
try {
|
||||
const today = getTodayISOString();
|
||||
console.log(`[getDailyPuzzle] Checking puzzle for date: ${today}`);
|
||||
|
||||
let dailyPuzzle = await prisma.dailyPuzzle.findUnique({
|
||||
where: { date: today },
|
||||
include: { song: true },
|
||||
});
|
||||
|
||||
if (!dailyPuzzle) {
|
||||
console.log('[getDailyPuzzle] No puzzle found, attempting to create...');
|
||||
const songsCount = await prisma.song.count();
|
||||
console.log(`[getDailyPuzzle] Found ${songsCount} songs in DB`);
|
||||
|
||||
if (songsCount > 0) {
|
||||
const skip = Math.floor(Math.random() * songsCount);
|
||||
const randomSong = await prisma.song.findFirst({ skip });
|
||||
|
||||
if (randomSong) {
|
||||
try {
|
||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||
data: { date: today, songId: randomSong.id },
|
||||
include: { song: true },
|
||||
});
|
||||
console.log(`[getDailyPuzzle] Created puzzle for song: ${randomSong.title}`);
|
||||
} catch (createError) {
|
||||
// Handle race condition: if another request created it in the meantime
|
||||
console.log('[getDailyPuzzle] Creation failed, trying to fetch again (likely race condition)');
|
||||
dailyPuzzle = await prisma.dailyPuzzle.findUnique({
|
||||
where: { date: today },
|
||||
include: { song: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[getDailyPuzzle] No songs available to create puzzle');
|
||||
}
|
||||
}
|
||||
|
||||
if (!dailyPuzzle) {
|
||||
console.log('[getDailyPuzzle] Failed to get or create puzzle');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dailyPuzzle.id,
|
||||
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
|
||||
songId: dailyPuzzle.songId,
|
||||
title: dailyPuzzle.song.title,
|
||||
artist: dailyPuzzle.song.artist,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[getDailyPuzzle] Error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function Home() {
|
||||
const dailyPuzzle = await getDailyPuzzle();
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
||||
const isEnded = s.endDate && s.endDate < now;
|
||||
return isStarted && !isEnded;
|
||||
});
|
||||
|
||||
const upcomingSpecials = specials.filter(s => {
|
||||
return s.launchDate && s.launchDate > now;
|
||||
});
|
||||
|
||||
return (
|
||||
<Game dailyPuzzle={dailyPuzzle} />
|
||||
<>
|
||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div className="tooltip">
|
||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
||||
<span className="tooltip-text">A random song from the entire collection</span>
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => (
|
||||
<div key={g.id} className="tooltip">
|
||||
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||
{g.name}
|
||||
</Link>
|
||||
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Active Specials */}
|
||||
{activeSpecials.map(s => (
|
||||
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div className="tooltip">
|
||||
<Link
|
||||
href={`/special/${s.name}`}
|
||||
style={{
|
||||
color: '#be185d', // Pink-700
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {s.name}
|
||||
</Link>
|
||||
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
|
||||
</div>
|
||||
{s.curator && (
|
||||
<span style={{ fontSize: '0.75rem', color: '#666' }}>
|
||||
Curated by {s.curator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Specials */}
|
||||
{upcomingSpecials.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
||||
Coming soon: {upcomingSpecials.map(s => (
|
||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||
★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: process.env.TZ
|
||||
}) : ''})
|
||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="tour-news">
|
||||
<NewsSection />
|
||||
</div>
|
||||
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||
<OnboardingTour />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
111
app/special/[name]/page.tsx
Normal file
111
app/special/[name]/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import Game from '@/components/Game';
|
||||
import NewsSection from '@/components/NewsSection';
|
||||
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ name: string }>;
|
||||
}
|
||||
|
||||
export default async function SpecialPage({ params }: PageProps) {
|
||||
const { name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
const currentSpecial = await prisma.special.findUnique({
|
||||
where: { name: decodedName }
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
|
||||
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
|
||||
|
||||
if (!currentSpecial || !isStarted) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h1>Special Not Available</h1>
|
||||
<p>This special has not launched yet or does not exist.</p>
|
||||
<Link href="/">Go Home</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEnded) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h1>Special Ended</h1>
|
||||
<p>This special event has ended.</p>
|
||||
<Link href="/">Go Home</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const activeSpecials = specials.filter(s => {
|
||||
const sStarted = !s.launchDate || s.launchDate <= now;
|
||||
const sEnded = s.endDate && s.endDate < now;
|
||||
return sStarted && !sEnded;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.map(g => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${g.name}`}
|
||||
style={{
|
||||
color: '#4b5563',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||
<span style={{ color: '#d1d5db' }}>|</span>
|
||||
)}
|
||||
|
||||
{/* Specials */}
|
||||
{activeSpecials.map(s => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/special/${s.name}`}
|
||||
style={{
|
||||
fontWeight: s.name === decodedName ? 'bold' : 'normal',
|
||||
textDecoration: s.name === decodedName ? 'underline' : 'none',
|
||||
color: s.name === decodedName ? '#9d174d' : '#be185d'
|
||||
}}
|
||||
>
|
||||
★ {s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<NewsSection />
|
||||
<Game
|
||||
dailyPuzzle={dailyPuzzle}
|
||||
genre={decodedName}
|
||||
isSpecial={true}
|
||||
maxAttempts={dailyPuzzle?.maxAttempts}
|
||||
unlockSteps={dailyPuzzle?.unlockSteps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
components/AppFooter.tsx
Normal file
34
components/AppFooter.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function AppFooter() {
|
||||
const [version, setVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/version')
|
||||
.then(res => res.json())
|
||||
.then(data => setVersion(data.version))
|
||||
.catch(() => setVersion(''));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Vibe coded with ☕ and 🍺 by{' '}
|
||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||
@elpatron@digitalcourage.social
|
||||
</a>
|
||||
{' '}- for personal use among friends only!
|
||||
{version && (
|
||||
<>
|
||||
{' '}·{' '}
|
||||
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
|
||||
{version}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,36 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||
import GuessInput from './GuessInput';
|
||||
import Statistics from './Statistics';
|
||||
import { useGameState } from '../lib/gameState';
|
||||
import { sendGotifyNotification } from '../app/actions';
|
||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||
|
||||
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 UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||
|
||||
export default function Game({ dailyPuzzle }: GameProps) {
|
||||
const { gameState, statistics, addGuess } = useGameState();
|
||||
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]);
|
||||
|
||||
@@ -38,107 +74,211 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
||||
setLastAction(null);
|
||||
}, [dailyPuzzle?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dailyPuzzle) {
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_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>
|
||||
<p>Could not generate a daily puzzle.</p>
|
||||
<p>Please ensure there are songs in the database.</p>
|
||||
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
|
||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
||||
</div>
|
||||
);
|
||||
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);
|
||||
// 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 >= 7) {
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
|
||||
setHasWon(false);
|
||||
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);
|
||||
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);
|
||||
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
|
||||
setHasWon(false);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||
};
|
||||
|
||||
const unlockedSeconds = UNLOCK_STEPS[Math.min(gameState.guesses.length, 6)];
|
||||
const handleYearGuess = (year: number) => {
|
||||
const correct = year === dailyPuzzle.releaseYear;
|
||||
addYearBonus(correct);
|
||||
setShowYearModal(false);
|
||||
|
||||
const handleShare = () => {
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||
};
|
||||
|
||||
const handleYearSkip = () => {
|
||||
skipYearBonus();
|
||||
setShowYearModal(false);
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
|
||||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||
|
||||
const handleShare = async () => {
|
||||
let emojiGrid = '';
|
||||
const totalGuesses = 7;
|
||||
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 text = `Hördle #${dailyPuzzle.id}\n\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://hoerdle.elpatron.me';
|
||||
if (genre) {
|
||||
if (isSpecial) {
|
||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||||
} else {
|
||||
shareUrl += `/${encodeURIComponent(genre)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #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('hoerdle_rated_puzzles') || '[]');
|
||||
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||
ratedPuzzles.push(dailyPuzzle.id);
|
||||
localStorage.setItem('hoerdle_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}</h1>
|
||||
<h1 id="tour-title" className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||
Next puzzle in: {timeUntilNext}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="game-board">
|
||||
|
||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||
<div className="status-bar">
|
||||
<span>Attempt {gameState.guesses.length + 1} / 7</span>
|
||||
<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">
|
||||
@@ -157,13 +297,19 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
||||
|
||||
{!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 (+{UNLOCK_STEPS[Math.min(gameState.guesses.length + 1, 6)] - unlockedSeconds}s)
|
||||
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||||
? 'Start'
|
||||
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -180,65 +326,295 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasWon && (
|
||||
<div className="message-box success">
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
|
||||
<p>Come back tomorrow for a new song.</p>
|
||||
{(hasWon || hasLost) && (
|
||||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
{hasWon ? 'You won!' : 'Game Over'}
|
||||
</h2>
|
||||
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
||||
Score: {gameState.score}
|
||||
</div>
|
||||
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
||||
<summary>Score Breakdown</summary>
|
||||
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||
{gameState.scoreBreakdown.map((item, i) => (
|
||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||||
<span>{item.reason}</span>
|
||||
<span style={{ fontWeight: 'bold', color: item.value >= 0 ? 'green' : 'red' }}>
|
||||
{item.value > 0 ? '+' : ''}{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
|
||||
|
||||
{/* Song Details */}
|
||||
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
{dailyPuzzle.coverImage && (
|
||||
<img
|
||||
src={dailyPuzzle.coverImage}
|
||||
alt="Album Cover"
|
||||
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||
alt="Album Cover"
|
||||
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
||||
/>
|
||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||
)}
|
||||
<audio controls style={{ width: '100%' }}>
|
||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<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: '#f3f4f6',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'monospace',
|
||||
cursor: 'help'
|
||||
}}>
|
||||
<span style={{ color: '#666' }}>{expression} = </span>
|
||||
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||
const [options, setOptions] = useState<number[]>([]);
|
||||
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: '#1f2937' }}>Bonus Round!</h3>
|
||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
{options.map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => handleGuess(year)}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: '#f3f4f6',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Skip Bonus
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
{feedback.guessedYear ? (
|
||||
feedback.correct ? (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#10b981', marginBottom: '0.5rem' }}>Correct!</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>Released in {correctYear}</p>
|
||||
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: '#10b981', marginTop: '1rem' }}>+10 Points!</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ef4444', marginBottom: '0.5rem' }}>Not quite!</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>You guessed {feedback.guessedYear}</p>
|
||||
<p style={{ fontSize: '1.2rem', color: '#4b5563', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>⏭️</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: '#6b7280', marginBottom: '0.5rem' }}>Skipped</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: '#4b5563' }}>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: '#666', 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: '#666', 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) ? '#ffc107' : '#9ca3af',
|
||||
transition: 'color 0.2s',
|
||||
padding: '0 0.25rem'
|
||||
}}
|
||||
onClick={() => {
|
||||
setRating(ratingValue);
|
||||
onRate(ratingValue);
|
||||
}}
|
||||
onMouseEnter={() => setHover(ratingValue)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
132
components/InstallPrompt.tsx
Normal file
132
components/InstallPrompt.tsx
Normal 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
199
components/NewsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
components/OnboardingTour.tsx
Normal file
109
components/OnboardingTour.tsx
Normal 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;
|
||||
}
|
||||
440
components/WaveformEditor.tsx
Normal file
440
components/WaveformEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,20 @@ 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
|
||||
- OPENROUTER_API_KEY=your_openrouter_api_key
|
||||
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
37
lib/auth.ts
Normal 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);
|
||||
}
|
||||
245
lib/dailyPuzzle.ts
Normal file
245
lib/dailyPuzzle.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
try {
|
||||
const today = getTodayISOString();
|
||||
let genreId: number | null = null;
|
||||
|
||||
if (genreName) {
|
||||
const genre = await prisma.genre.findUnique({
|
||||
where: { name: genreName }
|
||||
});
|
||||
if (genre) {
|
||||
genreId = genre.id;
|
||||
} else {
|
||||
return null; // Genre not found
|
||||
}
|
||||
}
|
||||
|
||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||
where: {
|
||||
date: today,
|
||||
genreId: genreId
|
||||
},
|
||||
include: { song: true },
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (!dailyPuzzle) {
|
||||
// Get songs available for this genre
|
||||
const whereClause = genreId
|
||||
? { genres: { some: { id: genreId } } }
|
||||
: { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
|
||||
|
||||
const allSongs = await prisma.song.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
puzzles: {
|
||||
where: { genreId: genreId }
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (allSongs.length === 0) {
|
||||
console.log(`[Daily Puzzle] No songs available for genre: ${genreName || 'Global'}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate weights
|
||||
const weightedSongs = allSongs.map(song => ({
|
||||
song,
|
||||
weight: 1.0 / (song.puzzles.length + 1),
|
||||
}));
|
||||
|
||||
// Calculate total weight
|
||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||
|
||||
// Pick a random song based on weights
|
||||
let random = Math.random() * totalWeight;
|
||||
let selectedSong = weightedSongs[0].song;
|
||||
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
selectedSong = item.song;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the daily puzzle
|
||||
try {
|
||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||
data: {
|
||||
date: today,
|
||||
songId: selectedSong.id,
|
||||
genreId: genreId
|
||||
},
|
||||
include: { song: true },
|
||||
});
|
||||
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genreName || 'Global'}) with song: ${selectedSong.title}`);
|
||||
} catch (e) {
|
||||
// Handle race condition
|
||||
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)');
|
||||
dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||
where: {
|
||||
date: today,
|
||||
genreId: genreId
|
||||
},
|
||||
include: { song: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in getOrCreateDailyPuzzle:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateSpecialPuzzle(specialName: string) {
|
||||
try {
|
||||
const today = getTodayISOString();
|
||||
|
||||
const special = await prisma.special.findUnique({
|
||||
where: { name: specialName }
|
||||
});
|
||||
|
||||
if (!special) return null;
|
||||
|
||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||
where: {
|
||||
date: today,
|
||||
specialId: special.id
|
||||
},
|
||||
include: { song: true },
|
||||
});
|
||||
|
||||
if (!dailyPuzzle) {
|
||||
// Get songs available for this special through SpecialSong
|
||||
const specialSongs = await prisma.specialSong.findMany({
|
||||
where: { specialId: special.id },
|
||||
include: {
|
||||
song: {
|
||||
include: {
|
||||
puzzles: {
|
||||
where: { specialId: special.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (specialSongs.length === 0) return null;
|
||||
|
||||
// Calculate weights
|
||||
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 selectedSpecialSong = weightedSongs[0].specialSong;
|
||||
|
||||
for (const item of weightedSongs) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
selectedSpecialSong = item.specialSong;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||
data: {
|
||||
date: today,
|
||||
songId: selectedSpecialSong.songId,
|
||||
specialId: special.id
|
||||
},
|
||||
include: { song: true },
|
||||
});
|
||||
} catch (e) {
|
||||
dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||
where: {
|
||||
date: today,
|
||||
specialId: special.id
|
||||
},
|
||||
include: { song: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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),
|
||||
startTime: specialSong?.startTime || 0
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in getOrCreateSpecialPuzzle:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
97
lib/fuzzyMatch.ts
Normal file
97
lib/fuzzyMatch.ts
Normal 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);
|
||||
}
|
||||
189
lib/gameState.ts
189
lib/gameState.ts
@@ -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,49 +27,68 @@ 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';
|
||||
|
||||
export function useGameState() {
|
||||
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 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 stored = localStorage.getItem(STORAGE_KEY);
|
||||
const storageKey = getStorageKey();
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
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(STORAGE_KEY, JSON.stringify(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(STORAGE_KEY, JSON.stringify(newState));
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
|
||||
// Load statistics
|
||||
const storedStats = localStorage.getItem(STATS_KEY);
|
||||
const statsKey = getStatsKey();
|
||||
const storedStats = localStorage.getItem(statsKey);
|
||||
if (storedStats) {
|
||||
const parsedStats = JSON.parse(storedStats);
|
||||
// Migration for existing stats without solvedIn7
|
||||
@@ -84,13 +108,13 @@ export function useGameState() {
|
||||
failed: 0,
|
||||
};
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
|
||||
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
||||
}
|
||||
}, []);
|
||||
}, [genre]); // Re-run when genre changes
|
||||
|
||||
const saveState = (newState: GameState) => {
|
||||
setGameState(newState);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
||||
};
|
||||
|
||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
||||
@@ -107,13 +131,15 @@ export function useGameState() {
|
||||
case 5: newStats.solvedIn5++; break;
|
||||
case 6: newStats.solvedIn6++; break;
|
||||
case 7: newStats.solvedIn7++; break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
newStats.failed++;
|
||||
}
|
||||
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
|
||||
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
||||
};
|
||||
|
||||
const addGuess = (guess: string, correct: boolean) => {
|
||||
@@ -121,7 +147,34 @@ export function useGameState() {
|
||||
|
||||
const newGuesses = [...gameState.guesses, guess];
|
||||
const isSolved = correct;
|
||||
const isFailed = !correct && newGuesses.length >= 7;
|
||||
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,
|
||||
@@ -129,6 +182,10 @@ export function useGameState() {
|
||||
isSolved,
|
||||
isFailed,
|
||||
lastPlayed: Date.now(),
|
||||
score: newScore,
|
||||
scoreBreakdown: newBreakdown,
|
||||
// Update skip count if skipped
|
||||
skipCount: guess === 'SKIPPED' ? gameState.skipCount + 1 : gameState.skipCount
|
||||
};
|
||||
|
||||
saveState(newState);
|
||||
@@ -139,5 +196,79 @@ export function useGameState() {
|
||||
}
|
||||
};
|
||||
|
||||
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
125
lib/itunes.ts
Normal 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
76
lib/rateLimit.ts
Normal 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
52
middleware.ts
Normal 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).*)',
|
||||
],
|
||||
};
|
||||
@@ -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
1240
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -5,24 +5,28 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "prisma migrate deploy && next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"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",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"prisma": "^6.19.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
prisma/dev.db.bak
Normal file
BIN
prisma/dev.db.bak
Normal file
Binary file not shown.
45
prisma/migrations/20251122121934_add_specials/migration.sql
Normal file
45
prisma/migrations/20251122121934_add_specials/migration.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "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
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Genre" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DailyPuzzle" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"date" TEXT NOT NULL,
|
||||
"songId" INTEGER NOT NULL,
|
||||
"genreId" INTEGER,
|
||||
CONSTRAINT "DailyPuzzle_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "DailyPuzzle_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_GenreToSong" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
CONSTRAINT "_GenreToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Genre" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "_GenreToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DailyPuzzle_date_genreId_key" ON "DailyPuzzle"("date", "genreId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_GenreToSong_AB_unique" ON "_GenreToSong"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_GenreToSong_B_index" ON "_GenreToSong"("B");
|
||||
@@ -0,0 +1,45 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "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
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_SongToSpecial" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
CONSTRAINT "_SongToSpecial_A_fkey" FOREIGN KEY ("A") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "_SongToSpecial_B_fkey" FOREIGN KEY ("B") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 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 RESTRICT 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") SELECT "date", "genreId", "id", "songId" 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;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Special_name_key" ON "Special"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_SongToSpecial_AB_unique" ON "_SongToSpecial"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_SongToSpecial_B_index" ON "_SongToSpecial"("B");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Special" ADD COLUMN "curator" TEXT;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Genre" ADD COLUMN "subtitle" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Special" ADD COLUMN "subtitle" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Song" ADD COLUMN "releaseYear" INTEGER;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -16,13 +16,75 @@ model Song {
|
||||
artist String
|
||||
filename String // Filename in public/uploads
|
||||
coverImage String? // Filename in public/uploads/covers
|
||||
releaseYear Int? // Release year from iTunes
|
||||
createdAt DateTime @default(now())
|
||||
puzzles DailyPuzzle[]
|
||||
genres Genre[]
|
||||
specials SpecialSong[]
|
||||
averageRating Float @default(0)
|
||||
ratingCount Int @default(0)
|
||||
excludeFromGlobal Boolean @default(false)
|
||||
}
|
||||
|
||||
model Genre {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
subtitle String?
|
||||
active Boolean @default(true)
|
||||
songs Song[]
|
||||
dailyPuzzles DailyPuzzle[]
|
||||
}
|
||||
|
||||
model Special {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
subtitle String?
|
||||
maxAttempts Int @default(7)
|
||||
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
|
||||
createdAt DateTime @default(now())
|
||||
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 @unique // Format: YYYY-MM-DD
|
||||
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?
|
||||
special Special? @relation(fields: [specialId], references: [id])
|
||||
|
||||
@@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
18
scripts/baseline-migrations.sh
Executable 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
72
scripts/deploy.sh
Executable 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
|
||||
20
scripts/docker-entrypoint.sh
Executable file
20
scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +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
24
scripts/hash-password.js
Normal 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.');
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
82
scripts/restore_songs.ts
Normal file
82
scripts/restore_songs.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { parseFile } from 'music-metadata';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const UPLOADS_DIR = path.join(process.cwd(), 'public/uploads');
|
||||
|
||||
async function restoreSongs() {
|
||||
console.log('Starting song restoration...');
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(UPLOADS_DIR);
|
||||
const mp3Files = files.filter(f => f.endsWith('.mp3'));
|
||||
|
||||
console.log(`Found ${mp3Files.length} MP3 files.`);
|
||||
|
||||
for (const filename of mp3Files) {
|
||||
// Check if song already exists
|
||||
const existing = await prisma.song.findFirst({
|
||||
where: { filename }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log(`Skipping ${filename} (already exists)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
try {
|
||||
const metadata = await parseFile(filePath);
|
||||
|
||||
const title = metadata.common.title || 'Unknown Title';
|
||||
const artist = metadata.common.artist || 'Unknown Artist';
|
||||
const genres = metadata.common.genre || [];
|
||||
|
||||
// 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,
|
||||
genres: {
|
||||
connect: genreConnect
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Restored: ${title} - ${artist}`);
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Failed to process ${filename}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Restoration complete.');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error reading uploads directory:', e);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
restoreSongs();
|
||||
211
scripts/slow-refresh-itunes.js
Normal file
211
scripts/slow-refresh-itunes.js
Normal 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();
|
||||
@@ -1,41 +1,55 @@
|
||||
# Hördle - Walkthrough
|
||||
# Genre/Tag System Implementation Walkthrough
|
||||
|
||||
Die Hördle Webapp ist nun einsatzbereit. Hier ist eine Anleitung, wie du sie nutzt und verwaltest.
|
||||
## Overview
|
||||
Implemented a comprehensive Genre/Tag system for Hördle, allowing songs to be categorized and users to play genre-specific daily puzzles.
|
||||
|
||||
## Starten der App
|
||||
## Changes
|
||||
|
||||
1. Öffne ein Terminal im Projektverzeichnis: `/home/markus/hördle`
|
||||
2. Starte den Entwicklungsserver:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
3. Öffne `http://localhost:3000` im Browser.
|
||||
### Database
|
||||
- **New Model**: `Genre` (id, name, songs, dailyPuzzles).
|
||||
- **Updated Model**: `Song` (added M-N relation to `Genre`).
|
||||
- **Updated Model**: `DailyPuzzle` (added optional `genreId`, updated unique constraint to `[date, genreId]`).
|
||||
|
||||
## Admin-Bereich (Songs hochladen)
|
||||
### Backend API
|
||||
- **`app/api/genres/route.ts`**: New endpoints for GET (list) and POST (create) and DELETE genres.
|
||||
- **`app/api/songs/route.ts`**: Updated to handle genre assignment (POST/PUT) and retrieval (GET).
|
||||
- **`app/api/daily/route.ts`**: Updated to support `?genre=<name>` query parameter.
|
||||
- **`lib/dailyPuzzle.ts`**: Shared logic for fetching/creating daily puzzles (Global or Genre-specific).
|
||||
|
||||
1. Gehe zu `http://localhost:3000/admin`
|
||||
2. Logge dich ein. Das Standard-Passwort ist `admin123` (kann in `.env` geändert werden).
|
||||
3. **Upload**: Wähle eine MP3-Datei aus. Titel und Interpret werden automatisch aus den ID3-Tags ausgelesen, falls du die Felder leer lässt.
|
||||
4. **Bibliothek**: Unter dem Upload-Formular siehst du eine Tabelle aller verfügbaren Songs.
|
||||
### Frontend (Admin)
|
||||
- **Genre Management**: Create and delete genres.
|
||||
- **Song Assignment**: Assign genres during upload and edit.
|
||||
- **Post-Upload Workflow**: Prompt to assign genres immediately after upload.
|
||||
- **Song List**: Display assigned genres in the table.
|
||||
|
||||
## Spielablauf
|
||||
### Frontend (User)
|
||||
- **Genre Selection**: Links on the main page to switch between Global and Genre-specific games.
|
||||
- **Game Logic**: Refactored to support independent game states per genre (localStorage keys: `hoerdle_game_state_<genre>`).
|
||||
- **Dynamic Route**: `app/[genre]/page.tsx` for genre-specific URLs.
|
||||
- **Sharing**: Share text now includes the genre name.
|
||||
|
||||
- Das Spiel wählt jeden Tag (um Mitternacht) automatisch einen neuen Song aus der Datenbank.
|
||||
- Wenn noch kein Song für den heutigen Tag festgelegt wurde, wird beim ersten Aufruf der Seite zufällig einer ausgewählt.
|
||||
- Der Spieler hat 6 Versuche.
|
||||
- Der Fortschritt wird im LocalStorage des Browsers gespeichert.
|
||||
### Deployment
|
||||
- **Auto-Migration**: Added `scripts/docker-entrypoint.sh` to run `prisma migrate deploy` on startup.
|
||||
- **Dockerfile**: Updated to use the entrypoint script.
|
||||
- **Dependencies**: Moved `prisma` to `dependencies` in `package.json`.
|
||||
|
||||
## Technologien
|
||||
## Verification Results
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Datenbank**: SQLite (via Prisma)
|
||||
- **Styling**: Vanilla CSS (in `app/globals.css`)
|
||||
- **State**: React Hooks + LocalStorage
|
||||
### Automated Build
|
||||
- `npm run build` passed successfully.
|
||||
- `npx prisma generate` passed.
|
||||
|
||||
## Wichtige Dateien
|
||||
|
||||
- `app/page.tsx`: Hauptseite des Spiels.
|
||||
- `components/Game.tsx`: Die Spiellogik.
|
||||
- `components/AudioPlayer.tsx`: Der Audio-Player mit Segment-Logik.
|
||||
- `app/api/daily/route.ts`: API für das tägliche Rätsel.
|
||||
- `prisma/schema.prisma`: Datenbank-Schema.
|
||||
### Manual Verification Steps (Recommended)
|
||||
1. **Admin Dashboard**:
|
||||
* Go to `/admin`.
|
||||
* Create a new genre (e.g., "Rock").
|
||||
* Upload a song and assign "Rock" to it.
|
||||
* Edit an existing song and assign "Rock".
|
||||
2. **User Interface**:
|
||||
* Go to `/`. Verify "Global" game works.
|
||||
* Click "Rock". Verify URL changes to `/Rock`.
|
||||
* Play the "Rock" game. Verify it picks a song tagged with "Rock".
|
||||
* Verify stats are separate for Global and Rock.
|
||||
3. **Deployment**:
|
||||
* Deploy to Docker.
|
||||
* Verify migrations run automatically on startup.
|
||||
|
||||
Reference in New Issue
Block a user