Compare commits
77 Commits
cf43adf63b
...
v0.1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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/*
|
||||||
!/public/uploads/.gitkeep
|
!/public/uploads/.gitkeep
|
||||||
/data
|
/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.
|
||||||
19
Dockerfile
19
Dockerfile
@@ -13,9 +13,24 @@ RUN npm ci
|
|||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
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 --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
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.
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
# 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/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
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/prisma ./prisma
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Create uploads directory and set permissions
|
# Create uploads directory and set permissions
|
||||||
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
|
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
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
159
README.md
159
README.md
@@ -8,23 +8,53 @@ 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).
|
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
|
||||||
- **Admin Dashboard:**
|
- **Admin Dashboard:**
|
||||||
- Upload von MP3-Dateien.
|
- 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).
|
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
|
||||||
|
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
|
||||||
- Bearbeitung von Metadaten.
|
- 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.
|
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||||
- **Cover Art:**
|
- **Cover Art:**
|
||||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||||
- Automatische Migration bestehender Songs.
|
- 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).
|
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
||||||
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
||||||
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
||||||
- **Genre-Management:**
|
- **Genre-Management:**
|
||||||
- Erstellen und Verwalten von Musik-Genres.
|
- 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.
|
- Manuelle Zuweisung von Genres zu Songs.
|
||||||
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
||||||
- Genre-spezifische tägliche Rätsel.
|
- 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.
|
||||||
|
- Manuelle Speicherung mit visueller Bestätigung.
|
||||||
|
|
||||||
|
## 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
|
## Tech Stack
|
||||||
|
|
||||||
@@ -69,8 +99,11 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
cp docker-compose.example.yml docker-compose.yml
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
```
|
```
|
||||||
Passe die Umgebungsvariablen in der `docker-compose.yml` an:
|
Passe die Umgebungsvariablen in der `docker-compose.yml` an:
|
||||||
- `ADMIN_PASSWORD`: Admin-Passwort (Standard: `admin123`)
|
- `ADMIN_PASSWORD`: Admin-Passwort als Bcrypt-Hash.
|
||||||
- `TZ`: Zeitzone für täglichen Puzzle-Wechsel (Standard: `Europe/Berlin`)
|
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_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||||
@@ -89,7 +122,24 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
|
|
||||||
4. **Admin-Zugang:**
|
4. **Admin-Zugang:**
|
||||||
- URL: `/admin`
|
- 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)
|
## Nginx-Konfiguration (für Reverse Proxy)
|
||||||
|
|
||||||
@@ -123,6 +173,92 @@ server {
|
|||||||
|
|
||||||
Eine vollständige Beispiel-Konfiguration findest du in `nginx.conf.example`.
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
### Audio-Dateien lassen sich nicht abspielen (in Produktion mit Nginx)
|
### Audio-Dateien lassen sich nicht abspielen (in Produktion mit Nginx)
|
||||||
@@ -140,6 +276,19 @@ Eine vollständige Beispiel-Konfiguration findest du in `nginx.conf.example`.
|
|||||||
- `proxy_buffering off;` - Deaktiviert Buffering für große Dateien
|
- `proxy_buffering off;` - Deaktiviert Buffering für große Dateien
|
||||||
- `client_max_body_size 50M;` - Erlaubt große Uploads
|
- `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
|
## Lizenz
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Game from '@/components/Game';
|
|||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -14,10 +15,34 @@ interface PageProps {
|
|||||||
export default async function GenrePage({ params }: PageProps) {
|
export default async function GenrePage({ params }: PageProps) {
|
||||||
const { genre } = await params;
|
const { genre } = await params;
|
||||||
const decodedGenre = decodeURIComponent(genre);
|
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 dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
||||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
const 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
@@ -40,12 +65,12 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
{/* Separator if both exist */}
|
||||||
{genres.length > 0 && specials.length > 0 && (
|
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
<span style={{ color: '#d1d5db' }}>|</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Specials */}
|
{/* Specials */}
|
||||||
{specials.map(s => (
|
{activeSpecials.map(s => (
|
||||||
<Link
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
href={`/special/${s.name}`}
|
href={`/special/${s.name}`}
|
||||||
@@ -59,6 +84,23 @@ export default async function GenrePage({ params }: PageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
const GOTIFY_URL = process.env.GOTIFY_URL;
|
const GOTIFY_URL = process.env.GOTIFY_URL;
|
||||||
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
|
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
|
||||||
|
|
||||||
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) {
|
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null, score?: number) {
|
||||||
try {
|
try {
|
||||||
const genreText = genre ? `[${genre}] ` : '';
|
const genreText = genre ? `[${genre}] ` : '';
|
||||||
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
|
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
|
||||||
|
const scoreText = score !== undefined ? ` with a score of ${score}` : '';
|
||||||
const message = status === 'won'
|
const message = status === 'won'
|
||||||
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).`
|
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s)${scoreText}.`
|
||||||
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`;
|
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s)${scoreText}.`;
|
||||||
|
|
||||||
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
|
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -30,3 +31,52 @@ export async function sendGotifyNotification(attempts: number, status: 'won' | '
|
|||||||
console.error('Error sending Gotify notification:', error);
|
console.error('Error sending Gotify notification:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function submitRating(songId: number, rating: number, genre?: string | null, isSpecial?: boolean, puzzleNumber?: number) {
|
||||||
|
try {
|
||||||
|
const song = await prisma.song.findUnique({ where: { id: songId } });
|
||||||
|
if (!song) throw new Error('Song not found');
|
||||||
|
|
||||||
|
const newRatingCount = song.ratingCount + 1;
|
||||||
|
const newAverageRating = ((song.averageRating * song.ratingCount) + rating) / newRatingCount;
|
||||||
|
|
||||||
|
await prisma.song.update({
|
||||||
|
where: { id: songId },
|
||||||
|
data: {
|
||||||
|
averageRating: newAverageRating,
|
||||||
|
ratingCount: newRatingCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send Gotify notification for the rating
|
||||||
|
let context = 'Global Daily Puzzle';
|
||||||
|
if (genre) {
|
||||||
|
context = isSpecial ? `Special Puzzle "${genre}"` : `Genre Puzzle "${genre}"`;
|
||||||
|
}
|
||||||
|
if (puzzleNumber) {
|
||||||
|
context += ` #${puzzleNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = `Hördle Rating: ${rating} Stars`;
|
||||||
|
// Do not show song title/artist to avoid spoilers
|
||||||
|
const message = `${context} received a ${rating}-star rating.\nNew Average: ${newAverageRating.toFixed(2)} (${newRatingCount} ratings)`;
|
||||||
|
|
||||||
|
await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
priority: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, averageRating: newAverageRating };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting rating:', error);
|
||||||
|
return { success: false, error: 'Failed to submit rating' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -42,7 +43,20 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(formattedPuzzles);
|
// Filter out duplicates (keep only the first one per category)
|
||||||
|
// This matches the behavior of getOrCreateDailyPuzzle which uses findFirst
|
||||||
|
const uniquePuzzles = [];
|
||||||
|
const seenCategories = new Set();
|
||||||
|
|
||||||
|
for (const puzzle of formattedPuzzles) {
|
||||||
|
const key = `${puzzle.categoryType}-${puzzle.genreId || 'null'}-${puzzle.specialId || 'null'}`;
|
||||||
|
if (!seenCategories.has(key)) {
|
||||||
|
seenCategories.add(key);
|
||||||
|
uniquePuzzles.push(puzzle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(uniquePuzzles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching daily puzzles:', error);
|
console.error('Error fetching daily puzzles:', error);
|
||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
@@ -50,6 +64,10 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { puzzleId } = await request.json();
|
const { puzzleId } = await request.json();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
const { password } = await request.json();
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,28 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { filename } = await params;
|
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);
|
const filePath = path.join(process.cwd(), 'public/uploads', filename);
|
||||||
|
|
||||||
|
// Security: Verify the resolved path is still within uploads directory
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'public/uploads');
|
||||||
|
const resolvedPath = path.resolve(filePath);
|
||||||
|
if (!resolvedPath.startsWith(uploadsDir)) {
|
||||||
|
return new NextResponse('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
try {
|
try {
|
||||||
await stat(filePath);
|
await stat(filePath);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ interface CategorizeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!OPENROUTER_API_KEY) {
|
if (!OPENROUTER_API_KEY) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -21,15 +22,23 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name } = await request.json();
|
const { name, subtitle, active } = await request.json();
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const genre = await prisma.genre.create({
|
const genre = await prisma.genre.create({
|
||||||
data: { name: name.trim() },
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
subtitle: subtitle ? subtitle.trim() : null,
|
||||||
|
active: active !== undefined ? active : true
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(genre);
|
return NextResponse.json(genre);
|
||||||
@@ -40,6 +49,10 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
|
|
||||||
@@ -57,3 +70,31 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,20 +3,30 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { writeFile, unlink } from 'fs/promises';
|
import { writeFile, unlink } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
|
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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() {
|
export async function GET() {
|
||||||
const songs = await prisma.song.findMany({
|
const songs = await prisma.song.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
puzzles: true,
|
puzzles: true,
|
||||||
genres: true,
|
genres: true,
|
||||||
specials: true,
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map to include activation count
|
// Map to include activation count and flatten specials
|
||||||
const songsWithActivations = songs.map(song => ({
|
const songsWithActivations = songs.map(song => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title,
|
title: song.title,
|
||||||
@@ -24,27 +34,70 @@ export async function GET() {
|
|||||||
filename: song.filename,
|
filename: song.filename,
|
||||||
createdAt: song.createdAt,
|
createdAt: song.createdAt,
|
||||||
coverImage: song.coverImage,
|
coverImage: song.coverImage,
|
||||||
|
releaseYear: song.releaseYear,
|
||||||
activations: song.puzzles.length,
|
activations: song.puzzles.length,
|
||||||
puzzles: song.puzzles,
|
puzzles: song.puzzles,
|
||||||
genres: song.genres,
|
genres: song.genres,
|
||||||
specials: song.specials,
|
specials: song.specials.map(ss => ss.special),
|
||||||
|
averageRating: song.averageRating,
|
||||||
|
ratingCount: song.ratingCount,
|
||||||
|
excludeFromGlobal: song.excludeFromGlobal,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(songsWithActivations);
|
return NextResponse.json(songsWithActivations);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
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 {
|
try {
|
||||||
|
console.log('[UPLOAD] Parsing form data...');
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
let title = '';
|
let title = '';
|
||||||
let artist = '';
|
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) {
|
if (!file) {
|
||||||
|
console.error('[UPLOAD] No file provided');
|
||||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
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());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
|
||||||
|
|
||||||
// Validate and extract metadata from file
|
// Validate and extract metadata from file
|
||||||
let metadata;
|
let metadata;
|
||||||
@@ -66,8 +119,16 @@ export async function POST(request: Request) {
|
|||||||
if (metadata.common.title) {
|
if (metadata.common.title) {
|
||||||
title = 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;
|
artist = metadata.common.artist;
|
||||||
|
} else if (metadata.common.albumartist) {
|
||||||
|
// Fallback to album artist
|
||||||
|
artist = metadata.common.albumartist;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation info
|
// Validation info
|
||||||
@@ -108,6 +169,28 @@ export async function POST(request: Request) {
|
|||||||
if (!title) title = 'Unknown Title';
|
if (!title) title = 'Unknown Title';
|
||||||
if (!artist) artist = 'Unknown Artist';
|
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
|
// Create URL-safe filename
|
||||||
const originalName = file.name.replace(/\.mp3$/i, '');
|
const originalName = file.name.replace(/\.mp3$/i, '');
|
||||||
const sanitizedName = originalName
|
const sanitizedName = originalName
|
||||||
@@ -142,12 +225,27 @@ export async function POST(request: Request) {
|
|||||||
console.error('Failed to extract cover image:', e);
|
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({
|
const song = await prisma.song.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
filename,
|
filename,
|
||||||
coverImage,
|
coverImage,
|
||||||
|
releaseYear,
|
||||||
|
excludeFromGlobal,
|
||||||
},
|
},
|
||||||
include: { genres: true, specials: true }
|
include: { genres: true, specials: true }
|
||||||
});
|
});
|
||||||
@@ -163,8 +261,12 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, title, artist, genreIds, specialIds } = await request.json();
|
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||||
|
|
||||||
if (!id || !title || !artist) {
|
if (!id || !title || !artist) {
|
||||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||||
@@ -172,22 +274,66 @@ export async function PUT(request: Request) {
|
|||||||
|
|
||||||
const data: any = { title, artist };
|
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) {
|
if (genreIds) {
|
||||||
data.genres = {
|
data.genres = {
|
||||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (specialIds) {
|
// Handle SpecialSong relations separately
|
||||||
data.specials = {
|
if (specialIds !== undefined) {
|
||||||
set: specialIds.map((sId: number) => ({ id: sId }))
|
// 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({
|
const updatedSong = await prisma.song.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data,
|
data,
|
||||||
include: { genres: true, specials: true }
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(updatedSong);
|
return NextResponse.json(updatedSong);
|
||||||
@@ -198,6 +344,10 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,49 @@
|
|||||||
import { PrismaClient, Special } from '@prisma/client';
|
import { PrismaClient, Special } from '@prisma/client';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const specials = await prisma.special.findMany({
|
const specials = await prisma.special.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { songs: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return NextResponse.json(specials);
|
return NextResponse.json(specials);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]' } = await request.json();
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const special = await prisma.special.create({
|
const special = await prisma.special.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
|
subtitle: subtitle || null,
|
||||||
maxAttempts: Number(maxAttempts),
|
maxAttempts: Number(maxAttempts),
|
||||||
unlockSteps,
|
unlockSteps,
|
||||||
|
launchDate: launchDate ? new Date(launchDate) : null,
|
||||||
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
|
curator: curator || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(special);
|
return NextResponse.json(special);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||||
@@ -35,7 +53,11 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
const { id, name, maxAttempts, unlockSteps } = await request.json();
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -43,8 +65,12 @@ export async function PUT(request: Request) {
|
|||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: {
|
||||||
...(name && { name }),
|
...(name && { name }),
|
||||||
|
subtitle: subtitle || null, // Allow clearing or setting
|
||||||
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
|
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
|
||||||
...(unlockSteps && { unlockSteps }),
|
...(unlockSteps && { unlockSteps }),
|
||||||
|
launchDate: launchDate ? new Date(launchDate) : null,
|
||||||
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
|
curator: curator || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(updated);
|
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;
|
border-radius: 0.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
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 {
|
.btn-primary:hover {
|
||||||
background: #333;
|
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 */
|
/* Footer */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@@ -363,4 +410,50 @@ body {
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #000;
|
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;
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,9 @@ export const viewport: Viewport = {
|
|||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -33,15 +36,8 @@ export default function RootLayout({
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
<footer className="app-footer">
|
<InstallPrompt />
|
||||||
<p>
|
<AppFooter />
|
||||||
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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
85
app/page.tsx
85
app/page.tsx
@@ -9,42 +9,89 @@ const prisma = new PrismaClient();
|
|||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
const 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
<div className="tooltip">
|
||||||
|
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
||||||
|
<span className="tooltip-text">A random song from the entire collection</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Genres */}
|
{/* Genres */}
|
||||||
{genres.map(g => (
|
{genres.map(g => (
|
||||||
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
<div key={g.id} className="tooltip">
|
||||||
{g.name}
|
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||||
</Link>
|
{g.name}
|
||||||
|
</Link>
|
||||||
|
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
{/* Separator if both exist */}
|
||||||
{genres.length > 0 && specials.length > 0 && (
|
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
<span style={{ color: '#d1d5db' }}>|</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Specials */}
|
{/* Active Specials */}
|
||||||
{specials.map(s => (
|
{activeSpecials.map(s => (
|
||||||
<Link
|
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
key={s.id}
|
<div className="tooltip">
|
||||||
href={`/special/${s.name}`}
|
<Link
|
||||||
style={{
|
href={`/special/${s.name}`}
|
||||||
color: '#be185d', // Pink-700
|
style={{
|
||||||
textDecoration: 'none',
|
color: '#be185d', // Pink-700
|
||||||
fontWeight: '500'
|
textDecoration: 'none',
|
||||||
}}
|
fontWeight: '500'
|
||||||
>
|
}}
|
||||||
★ {s.name}
|
>
|
||||||
</Link>
|
★ {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>
|
</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>
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,10 +14,45 @@ interface PageProps {
|
|||||||
export default async function SpecialPage({ params }: PageProps) {
|
export default async function SpecialPage({ params }: PageProps) {
|
||||||
const { name } = await params;
|
const { name } = await params;
|
||||||
const decodedName = decodeURIComponent(name);
|
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 dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
|
||||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
|
||||||
|
const activeSpecials = specials.filter(s => {
|
||||||
|
const sStarted = !s.launchDate || s.launchDate <= now;
|
||||||
|
const sEnded = s.endDate && s.endDate < now;
|
||||||
|
return sStarted && !sEnded;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
|
||||||
@@ -39,12 +74,12 @@ export default async function SpecialPage({ params }: PageProps) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
{/* Separator if both exist */}
|
||||||
{genres.length > 0 && specials.length > 0 && (
|
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
<span style={{ color: '#d1d5db' }}>|</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Specials */}
|
{/* Specials */}
|
||||||
{specials.map(s => (
|
{activeSpecials.map(s => (
|
||||||
<Link
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
href={`/special/${s.name}`}
|
href={`/special/${s.name}`}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,21 +5,25 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
interface AudioPlayerProps {
|
interface AudioPlayerProps {
|
||||||
src: string;
|
src: string;
|
||||||
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
|
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
|
||||||
|
startTime?: number; // Start offset in seconds (for curated specials)
|
||||||
onPlay?: () => void;
|
onPlay?: () => void;
|
||||||
|
onReplay?: () => void;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = false }: AudioPlayerProps) {
|
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
audioRef.current.currentTime = 0;
|
audioRef.current.currentTime = startTime;
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
setHasPlayedOnce(false); // Reset for new segment
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
const playPromise = audioRef.current.play();
|
const playPromise = audioRef.current.play();
|
||||||
@@ -28,6 +32,7 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
onPlay?.();
|
onPlay?.();
|
||||||
|
setHasPlayedOnce(true);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.log("Autoplay prevented:", error);
|
console.log("Autoplay prevented:", error);
|
||||||
@@ -36,7 +41,7 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [src, unlockedSeconds, autoPlay]);
|
}, [src, unlockedSeconds, startTime, autoPlay]);
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
@@ -46,6 +51,12 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
|
|||||||
} else {
|
} else {
|
||||||
audioRef.current.play();
|
audioRef.current.play();
|
||||||
onPlay?.();
|
onPlay?.();
|
||||||
|
|
||||||
|
if (hasPlayedOnce) {
|
||||||
|
onReplay?.();
|
||||||
|
} else {
|
||||||
|
setHasPlayedOnce(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
};
|
};
|
||||||
@@ -54,12 +65,13 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f
|
|||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
const current = audioRef.current.currentTime;
|
const current = audioRef.current.currentTime;
|
||||||
const percent = (current / unlockedSeconds) * 100;
|
const elapsed = current - startTime;
|
||||||
|
const percent = (elapsed / unlockedSeconds) * 100;
|
||||||
setProgress(Math.min(percent, 100));
|
setProgress(Math.min(percent, 100));
|
||||||
|
|
||||||
if (current >= unlockedSeconds) {
|
if (elapsed >= unlockedSeconds) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
audioRef.current.currentTime = 0;
|
audioRef.current.currentTime = startTime;
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import AudioPlayer from './AudioPlayer';
|
|||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
import Statistics from './Statistics';
|
import Statistics from './Statistics';
|
||||||
import { useGameState } from '../lib/gameState';
|
import { useGameState } from '../lib/gameState';
|
||||||
import { sendGotifyNotification } from '../app/actions';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
|
||||||
interface GameProps {
|
interface GameProps {
|
||||||
dailyPuzzle: {
|
dailyPuzzle: {
|
||||||
id: number;
|
id: number;
|
||||||
|
puzzleNumber: number;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
songId: number;
|
songId: number;
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
|
releaseYear?: number | null;
|
||||||
|
startTime?: number;
|
||||||
} | null;
|
} | null;
|
||||||
genre?: string | null;
|
genre?: string | null;
|
||||||
isSpecial?: boolean;
|
isSpecial?: boolean;
|
||||||
@@ -25,17 +28,43 @@ interface GameProps {
|
|||||||
const DEFAULT_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, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||||||
const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
||||||
const [hasWon, setHasWon] = useState(false);
|
const [hasWon, setHasWon] = useState(false);
|
||||||
const [hasLost, setHasLost] = useState(false);
|
const [hasLost, setHasLost] = useState(false);
|
||||||
const [shareText, setShareText] = useState('🔗 Share');
|
const [shareText, setShareText] = useState('🔗 Share');
|
||||||
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
||||||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||||
|
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||||
|
const [hasRated, setHasRated] = useState(false);
|
||||||
|
const [showYearModal, setShowYearModal] = useState(false);
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (gameState && dailyPuzzle) {
|
if (gameState && dailyPuzzle) {
|
||||||
setHasWon(gameState.isSolved);
|
setHasWon(gameState.isSolved);
|
||||||
setHasLost(gameState.isFailed);
|
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]);
|
}, [gameState, dailyPuzzle]);
|
||||||
|
|
||||||
@@ -43,6 +72,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
setLastAction(null);
|
setLastAction(null);
|
||||||
}, [dailyPuzzle?.id]);
|
}, [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 (
|
if (!dailyPuzzle) return (
|
||||||
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
<h2>No Puzzle Available</h2>
|
<h2>No Puzzle Available</h2>
|
||||||
@@ -54,37 +94,62 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (!gameState) return <div>Loading state...</div>;
|
if (!gameState) return <div>Loading state...</div>;
|
||||||
|
|
||||||
const handleGuess = (song: any) => {
|
const handleGuess = (song: any) => {
|
||||||
if (isProcessingGuess) return; // Prevent multiple guesses
|
if (isProcessingGuess) return;
|
||||||
|
|
||||||
setIsProcessingGuess(true);
|
setIsProcessingGuess(true);
|
||||||
setLastAction('GUESS');
|
setLastAction('GUESS');
|
||||||
if (song.id === dailyPuzzle.songId) {
|
if (song.id === dailyPuzzle.songId) {
|
||||||
addGuess(song.title, true);
|
addGuess(song.title, true);
|
||||||
setHasWon(true);
|
setHasWon(true);
|
||||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre);
|
// Notification sent after year guess or skip
|
||||||
|
if (!dailyPuzzle.releaseYear) {
|
||||||
|
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
addGuess(song.title, false);
|
addGuess(song.title, false);
|
||||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false); // Ensure won is false
|
setHasWon(false);
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reset after a short delay to allow UI update
|
|
||||||
setTimeout(() => setIsProcessingGuess(false), 500);
|
setTimeout(() => setIsProcessingGuess(false), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
setLastAction('SKIP');
|
setLastAction('SKIP');
|
||||||
addGuess("SKIPPED", false);
|
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 = () => {
|
const handleGiveUp = () => {
|
||||||
setLastAction('SKIP');
|
setLastAction('SKIP');
|
||||||
addGuess("SKIPPED", false);
|
addGuess("SKIPPED", false);
|
||||||
|
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleYearGuess = (year: number) => {
|
||||||
|
const correct = year === dailyPuzzle.releaseYear;
|
||||||
|
addYearBonus(correct);
|
||||||
|
setShowYearModal(false);
|
||||||
|
|
||||||
|
// Send notification now that game is fully complete
|
||||||
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleYearSkip = () => {
|
||||||
|
skipYearBonus();
|
||||||
|
setShowYearModal(false);
|
||||||
|
// Send notification now that game is fully complete
|
||||||
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||||
@@ -93,26 +158,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
let emojiGrid = '';
|
let emojiGrid = '';
|
||||||
const totalGuesses = maxAttempts;
|
const totalGuesses = maxAttempts;
|
||||||
|
|
||||||
// Build the grid
|
|
||||||
for (let i = 0; i < totalGuesses; i++) {
|
for (let i = 0; i < totalGuesses; i++) {
|
||||||
if (i < gameState.guesses.length) {
|
if (i < gameState.guesses.length) {
|
||||||
// If this was the winning guess (last one and won)
|
|
||||||
if (hasWon && i === gameState.guesses.length - 1) {
|
if (hasWon && i === gameState.guesses.length - 1) {
|
||||||
emojiGrid += '🟩';
|
emojiGrid += '🟩';
|
||||||
} else {
|
} else if (gameState.guesses[i] === 'SKIPPED') {
|
||||||
// Wrong or skipped
|
|
||||||
emojiGrid += '⬛';
|
emojiGrid += '⬛';
|
||||||
|
} else {
|
||||||
|
emojiGrid += '🟥';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unused attempts
|
|
||||||
emojiGrid += '⬜';
|
emojiGrid += '⬜';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const speaker = hasWon ? '🔉' : '🔇';
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
const genreText = genre ? `Genre: ${genre}\n` : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
|
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
||||||
|
|
||||||
// Generate URL with genre/special path
|
|
||||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||||
if (genre) {
|
if (genre) {
|
||||||
if (isSpecial) {
|
if (isSpecial) {
|
||||||
@@ -122,29 +185,26 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `Hördle #${dailyPuzzle.id}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\n${shareUrl}`;
|
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||||
|
|
||||||
// Try native Web Share API only on mobile devices
|
|
||||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||||
|
|
||||||
if (isMobile && navigator.share) {
|
if (isMobile && navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: `Hördle #${dailyPuzzle.id}`,
|
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
||||||
text: text,
|
text: text,
|
||||||
});
|
});
|
||||||
setShareText('✓ Shared!');
|
setShareText('✓ Shared!');
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled or error - fall through to clipboard
|
|
||||||
if ((err as Error).name !== 'AbortError') {
|
if ((err as Error).name !== 'AbortError') {
|
||||||
console.error('Share failed:', err);
|
console.error('Share failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Copy to clipboard
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setShareText('✓ Copied!');
|
setShareText('✓ Copied!');
|
||||||
@@ -156,23 +216,47 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<h1 className="title">Hördle #{dailyPuzzle.id}{genre ? ` / ${genre}` : ''}</h1>
|
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||||
|
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '-0.5rem', marginBottom: '1rem' }}>
|
||||||
|
Next puzzle in: {timeUntilNext}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="game-board">
|
<main className="game-board">
|
||||||
|
|
||||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||||
<div className="status-bar">
|
<div className="status-bar">
|
||||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||||
<span>{unlockedSeconds}s unlocked</span>
|
<span>{unlockedSeconds}s unlocked</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||||
|
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
src={dailyPuzzle.audioUrl}
|
src={dailyPuzzle.audioUrl}
|
||||||
unlockedSeconds={unlockedSeconds}
|
unlockedSeconds={unlockedSeconds}
|
||||||
autoPlay={lastAction === 'SKIP'}
|
startTime={dailyPuzzle.startTime}
|
||||||
|
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||||
|
onReplay={addReplay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,7 +277,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
{!hasWon && !hasLost && (
|
{!hasWon && !hasLost && (
|
||||||
<>
|
<>
|
||||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||||
{gameState.guesses.length < 6 ? (
|
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="skip-button"
|
className="skip-button"
|
||||||
@@ -215,65 +299,246 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasWon && (
|
{(hasWon || hasLost) && (
|
||||||
<div className="message-box success">
|
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||||
<p>Come back tomorrow for a new song.</p>
|
{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' }}>
|
<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
|
||||||
<img
|
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||||
src={dailyPuzzle.coverImage}
|
alt="Album Cover"
|
||||||
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)' }}
|
||||||
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>
|
<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%' }}>
|
<audio controls style={{ width: '100%' }}>
|
||||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||||
Your browser does not support the audio element.
|
Your browser does not support the audio element.
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{statistics && <Statistics statistics={statistics} />}
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||||
{shareText}
|
{shareText}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
|
|
||||||
|
{showYearModal && dailyPuzzle.releaseYear && (
|
||||||
|
<YearGuessModal
|
||||||
|
correctYear={dailyPuzzle.releaseYear}
|
||||||
|
onGuess={handleYearGuess}
|
||||||
|
onSkip={handleYearSkip}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) {
|
||||||
|
const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n');
|
||||||
|
|
||||||
|
// Create expression: "90 - 2 - 5 + 10"
|
||||||
|
// Limit to last 5 items to avoid overflow if too long
|
||||||
|
const displayItems = breakdown.length > 5 ?
|
||||||
|
[{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] :
|
||||||
|
breakdown;
|
||||||
|
|
||||||
|
const expression = displayItems.map((item, index) => {
|
||||||
|
if (index === 0 && breakdown.length <= 5) return item.value.toString();
|
||||||
|
if (index === 0 && breakdown.length > 5) return `${item.value} ...`;
|
||||||
|
return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`;
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="score-display" title={tooltipText} style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
margin: '0.5rem 0',
|
||||||
|
padding: '0.5rem',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
cursor: 'help'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#666' }}>{expression} = </span>
|
||||||
|
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||||
|
const [options, setOptions] = useState<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const minYear = 1950;
|
||||||
|
|
||||||
|
const closeOptions = new Set<number>();
|
||||||
|
closeOptions.add(correctYear);
|
||||||
|
|
||||||
|
// Add 2 close years (+/- 2)
|
||||||
|
while (closeOptions.size < 3) {
|
||||||
|
const offset = Math.floor(Math.random() * 5) - 2;
|
||||||
|
const year = correctYear + offset;
|
||||||
|
if (year <= currentYear && year >= minYear && year !== correctYear) {
|
||||||
|
closeOptions.add(year);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOptions = new Set(closeOptions);
|
||||||
|
|
||||||
|
// Fill up to 10 with random years
|
||||||
|
while (allOptions.size < 10) {
|
||||||
|
const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear;
|
||||||
|
allOptions.add(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||||
|
}, [correctYear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
padding: '1rem'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
||||||
|
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||||
|
gap: '0.75rem',
|
||||||
|
marginBottom: '1.5rem'
|
||||||
|
}}>
|
||||||
|
{options.map(year => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
onClick={() => onGuess(year)}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#374151',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
||||||
|
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSkip}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#6b7280',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.9rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip Bonus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,7 +10,7 @@ services:
|
|||||||
- "3010:3000"
|
- "3010:3000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=file:/app/data/prod.db
|
- 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
|
- TZ=Europe/Berlin # Timezone for daily puzzle rotation
|
||||||
- GOTIFY_URL=https://gotify.example.com
|
- GOTIFY_URL=https://gotify.example.com
|
||||||
- GOTIFY_APP_TOKEN=your_gotify_token
|
- GOTIFY_APP_TOKEN=your_gotify_token
|
||||||
@@ -24,6 +24,6 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
# Initialize DB if needed and run migration
|
# Run migrations and start server (auto-baseline on first run if needed)
|
||||||
command: >
|
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);
|
||||||
|
}
|
||||||
@@ -27,13 +27,13 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
include: { song: true },
|
include: { song: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Daily Puzzle] Date: ${today}, Genre: ${genreName || 'Global'}, Found existing: ${!!dailyPuzzle}`);
|
|
||||||
|
|
||||||
if (!dailyPuzzle) {
|
if (!dailyPuzzle) {
|
||||||
// Get songs available for this genre
|
// Get songs available for this genre
|
||||||
const whereClause = genreId
|
const whereClause = genreId
|
||||||
? { genres: { some: { id: genreId } } }
|
? { genres: { some: { id: genreId } } }
|
||||||
: {}; // Global puzzle picks from ALL songs
|
: { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
|
||||||
|
|
||||||
const allSongs = await prisma.song.findMany({
|
const allSongs = await prisma.song.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
@@ -96,13 +96,29 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
|
|
||||||
if (!dailyPuzzle) return null;
|
if (!dailyPuzzle) return null;
|
||||||
|
|
||||||
|
// Calculate puzzle number (sequential day count)
|
||||||
|
const whereClause = genreId
|
||||||
|
? { genreId: genreId }
|
||||||
|
: { genreId: null, specialId: null };
|
||||||
|
|
||||||
|
const puzzleCount = await prisma.dailyPuzzle.count({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
date: {
|
||||||
|
lte: dailyPuzzle.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: dailyPuzzle.id,
|
id: dailyPuzzle.id,
|
||||||
|
puzzleNumber: puzzleCount,
|
||||||
audioUrl: `/api/audio/${dailyPuzzle.song.filename}`,
|
audioUrl: `/api/audio/${dailyPuzzle.song.filename}`,
|
||||||
songId: dailyPuzzle.songId,
|
songId: dailyPuzzle.songId,
|
||||||
title: dailyPuzzle.song.title,
|
title: dailyPuzzle.song.title,
|
||||||
artist: dailyPuzzle.song.artist,
|
artist: dailyPuzzle.song.artist,
|
||||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||||
|
releaseYear: dailyPuzzle.song.releaseYear,
|
||||||
genre: genreName
|
genre: genreName
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,32 +147,36 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!dailyPuzzle) {
|
if (!dailyPuzzle) {
|
||||||
// Get songs available for this special
|
// Get songs available for this special through SpecialSong
|
||||||
const allSongs = await prisma.song.findMany({
|
const specialSongs = await prisma.specialSong.findMany({
|
||||||
where: { specials: { some: { id: special.id } } },
|
where: { specialId: special.id },
|
||||||
include: {
|
include: {
|
||||||
puzzles: {
|
song: {
|
||||||
where: { specialId: special.id }
|
include: {
|
||||||
},
|
puzzles: {
|
||||||
},
|
where: { specialId: special.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allSongs.length === 0) return null;
|
if (specialSongs.length === 0) return null;
|
||||||
|
|
||||||
// Calculate weights
|
// Calculate weights
|
||||||
const weightedSongs = allSongs.map(song => ({
|
const weightedSongs = specialSongs.map(specialSong => ({
|
||||||
song,
|
specialSong,
|
||||||
weight: 1.0 / (song.puzzles.length + 1),
|
weight: 1.0 / (specialSong.song.puzzles.length + 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||||
let random = Math.random() * totalWeight;
|
let random = Math.random() * totalWeight;
|
||||||
let selectedSong = weightedSongs[0].song;
|
let selectedSpecialSong = weightedSongs[0].specialSong;
|
||||||
|
|
||||||
for (const item of weightedSongs) {
|
for (const item of weightedSongs) {
|
||||||
random -= item.weight;
|
random -= item.weight;
|
||||||
if (random <= 0) {
|
if (random <= 0) {
|
||||||
selectedSong = item.song;
|
selectedSpecialSong = item.specialSong;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +185,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||||
data: {
|
data: {
|
||||||
date: today,
|
date: today,
|
||||||
songId: selectedSong.id,
|
songId: selectedSpecialSong.songId,
|
||||||
specialId: special.id
|
specialId: special.id
|
||||||
},
|
},
|
||||||
include: { song: true },
|
include: { song: true },
|
||||||
@@ -183,16 +203,39 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
|
|
||||||
if (!dailyPuzzle) return null;
|
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 {
|
return {
|
||||||
id: dailyPuzzle.id,
|
id: dailyPuzzle.id,
|
||||||
|
puzzleNumber: puzzleCount,
|
||||||
audioUrl: `/api/audio/${dailyPuzzle.song.filename}`,
|
audioUrl: `/api/audio/${dailyPuzzle.song.filename}`,
|
||||||
songId: dailyPuzzle.songId,
|
songId: dailyPuzzle.songId,
|
||||||
title: dailyPuzzle.song.title,
|
title: dailyPuzzle.song.title,
|
||||||
artist: dailyPuzzle.song.artist,
|
artist: dailyPuzzle.song.artist,
|
||||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||||
|
releaseYear: dailyPuzzle.song.releaseYear,
|
||||||
special: specialName,
|
special: specialName,
|
||||||
maxAttempts: special.maxAttempts,
|
maxAttempts: special.maxAttempts,
|
||||||
unlockSteps: JSON.parse(special.unlockSteps)
|
unlockSteps: JSON.parse(special.unlockSteps),
|
||||||
|
startTime: specialSong?.startTime || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
167
lib/gameState.ts
167
lib/gameState.ts
@@ -9,6 +9,11 @@ export interface GameState {
|
|||||||
isSolved: boolean;
|
isSolved: boolean;
|
||||||
isFailed: boolean;
|
isFailed: boolean;
|
||||||
lastPlayed: number; // Timestamp
|
lastPlayed: number; // Timestamp
|
||||||
|
score: number;
|
||||||
|
replayCount: number;
|
||||||
|
skipCount: number;
|
||||||
|
scoreBreakdown: Array<{ value: number; reason: string }>;
|
||||||
|
yearGuessed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Statistics {
|
export interface Statistics {
|
||||||
@@ -22,19 +27,31 @@ export interface Statistics {
|
|||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'hoerdle_game_state';
|
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
||||||
const STATS_KEY = 'hoerdle_statistics';
|
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
||||||
|
|
||||||
|
const INITIAL_SCORE = 90;
|
||||||
|
|
||||||
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
|
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
|
||||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
|
||||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
|
||||||
|
|
||||||
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
||||||
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_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(() => {
|
useEffect(() => {
|
||||||
// Load game state
|
// Load game state
|
||||||
const storageKey = getStorageKey();
|
const storageKey = getStorageKey();
|
||||||
@@ -42,30 +59,29 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
|||||||
const today = getTodayISOString();
|
const today = getTodayISOString();
|
||||||
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed: GameState = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (parsed.date === today) {
|
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 {
|
} else {
|
||||||
// New day
|
// New day
|
||||||
const newState: GameState = {
|
const newState = createNewState(today);
|
||||||
date: today,
|
|
||||||
guesses: [],
|
|
||||||
isSolved: false,
|
|
||||||
isFailed: false,
|
|
||||||
lastPlayed: Date.now(),
|
|
||||||
};
|
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No state
|
// No state
|
||||||
const newState: GameState = {
|
const newState = createNewState(today);
|
||||||
date: today,
|
|
||||||
guesses: [],
|
|
||||||
isSolved: false,
|
|
||||||
isFailed: false,
|
|
||||||
lastPlayed: Date.now(),
|
|
||||||
};
|
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||||
}
|
}
|
||||||
@@ -116,8 +132,6 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
|||||||
case 6: newStats.solvedIn6++; break;
|
case 6: newStats.solvedIn6++; break;
|
||||||
case 7: newStats.solvedIn7++; break;
|
case 7: newStats.solvedIn7++; break;
|
||||||
default:
|
default:
|
||||||
// For custom attempts > 7, we currently don't have specific stats buckets
|
|
||||||
// We could add a 'solvedInOther' or just ignore for now
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -135,12 +149,43 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
|||||||
const isSolved = correct;
|
const isSolved = correct;
|
||||||
const isFailed = !correct && newGuesses.length >= maxAttempts;
|
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 = {
|
const newState = {
|
||||||
...gameState,
|
...gameState,
|
||||||
guesses: newGuesses,
|
guesses: newGuesses,
|
||||||
isSolved,
|
isSolved,
|
||||||
isFailed,
|
isFailed,
|
||||||
lastPlayed: Date.now(),
|
lastPlayed: Date.now(),
|
||||||
|
score: newScore,
|
||||||
|
scoreBreakdown: newBreakdown,
|
||||||
|
// Update skip count if skipped
|
||||||
|
skipCount: guess === 'SKIPPED' ? gameState.skipCount + 1 : gameState.skipCount
|
||||||
};
|
};
|
||||||
|
|
||||||
saveState(newState);
|
saveState(newState);
|
||||||
@@ -151,5 +196,79 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { gameState, statistics, addGuess };
|
const giveUp = () => {
|
||||||
|
if (!gameState || gameState.isSolved || gameState.isFailed) return;
|
||||||
|
|
||||||
|
let newScore = 0;
|
||||||
|
const newBreakdown = [...gameState.scoreBreakdown];
|
||||||
|
|
||||||
|
if (gameState.score > 0) {
|
||||||
|
newBreakdown.push({ value: -gameState.score, reason: 'Gave Up' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...gameState,
|
||||||
|
isFailed: true,
|
||||||
|
score: 0,
|
||||||
|
scoreBreakdown: newBreakdown,
|
||||||
|
lastPlayed: Date.now()
|
||||||
|
};
|
||||||
|
saveState(newState);
|
||||||
|
updateStatistics(gameState.guesses.length, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addReplay = () => {
|
||||||
|
if (!gameState || gameState.isSolved || gameState.isFailed) return;
|
||||||
|
|
||||||
|
let newScore = gameState.score - 1;
|
||||||
|
// Ensure score doesn't go below 0
|
||||||
|
newScore = Math.max(0, newScore);
|
||||||
|
|
||||||
|
const newBreakdown = [...gameState.scoreBreakdown, { value: -1, reason: 'Replay snippet' }];
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...gameState,
|
||||||
|
replayCount: gameState.replayCount + 1,
|
||||||
|
score: newScore,
|
||||||
|
scoreBreakdown: newBreakdown
|
||||||
|
};
|
||||||
|
saveState(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addYearBonus = (correct: boolean) => {
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
let newScore = gameState.score;
|
||||||
|
const newBreakdown = [...gameState.scoreBreakdown];
|
||||||
|
|
||||||
|
if (correct) {
|
||||||
|
newScore += 10;
|
||||||
|
newBreakdown.push({ value: 10, reason: 'Bonus: Correct Year' });
|
||||||
|
} else {
|
||||||
|
newBreakdown.push({ value: 0, reason: 'Bonus: Wrong Year' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...gameState,
|
||||||
|
score: newScore,
|
||||||
|
scoreBreakdown: newBreakdown,
|
||||||
|
yearGuessed: true
|
||||||
|
};
|
||||||
|
saveState(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipYearBonus = () => {
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const newBreakdown = [...gameState.scoreBreakdown, { value: 0, reason: 'Bonus: Skipped' }];
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...gameState,
|
||||||
|
scoreBreakdown: newBreakdown,
|
||||||
|
yearGuessed: true
|
||||||
|
};
|
||||||
|
saveState(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus };
|
||||||
}
|
}
|
||||||
|
|||||||
125
lib/itunes.ts
Normal file
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'", // Next.js requires unsafe-inline/eval
|
||||||
|
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self' https://openrouter.ai https://gotify.example.com",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
].join('; ');
|
||||||
|
headers.set('Content-Security-Policy', csp);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply middleware to all routes
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
|
|||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
middlewareClientMaxBodySize: '50mb',
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
TZ: process.env.TZ || 'Europe/Berlin',
|
TZ: process.env.TZ || 'Europe/Berlin',
|
||||||
|
|||||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -9,19 +9,21 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"music-metadata": "^11.10.2",
|
"music-metadata": "^11.10.2",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"prisma": "^6.19.0",
|
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1251,7 +1253,6 @@
|
|||||||
"version": "6.19.0",
|
"version": "6.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz",
|
||||||
"integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==",
|
"integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "3.1.0",
|
"c12": "3.1.0",
|
||||||
@@ -1264,14 +1265,12 @@
|
|||||||
"version": "6.19.0",
|
"version": "6.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz",
|
||||||
"integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==",
|
"integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "6.19.0",
|
"version": "6.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz",
|
||||||
"integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==",
|
"integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1285,14 +1284,12 @@
|
|||||||
"version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
|
"version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz",
|
||||||
"integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==",
|
"integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "6.19.0",
|
"version": "6.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz",
|
||||||
"integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==",
|
"integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.19.0",
|
"@prisma/debug": "6.19.0",
|
||||||
@@ -1304,7 +1301,6 @@
|
|||||||
"version": "6.19.0",
|
"version": "6.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz",
|
||||||
"integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==",
|
"integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.19.0"
|
"@prisma/debug": "6.19.0"
|
||||||
@@ -1321,7 +1317,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
@@ -1367,6 +1362,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2301,6 +2303,15 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -2363,7 +2374,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
@@ -2489,7 +2499,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
@@ -2505,7 +2514,6 @@
|
|||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"consola": "^3.2.3"
|
"consola": "^3.2.3"
|
||||||
@@ -2548,14 +2556,12 @@
|
|||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||||
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
|
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/consola": {
|
"node_modules/consola": {
|
||||||
"version": "3.4.2",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.18.0 || >=16.10.0"
|
"node": "^14.18.0 || >=16.10.0"
|
||||||
@@ -2688,7 +2694,6 @@
|
|||||||
"version": "7.1.5",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
@@ -2734,14 +2739,12 @@
|
|||||||
"version": "6.1.4",
|
"version": "6.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/destr": {
|
"node_modules/destr": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
@@ -2771,7 +2774,6 @@
|
|||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -2799,7 +2801,6 @@
|
|||||||
"version": "3.18.4",
|
"version": "3.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||||
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
|
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
@@ -2824,7 +2825,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -3458,14 +3458,12 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-check": {
|
"node_modules/fast-check": {
|
||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -3778,7 +3776,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"citty": "^0.1.6",
|
"citty": "^0.1.6",
|
||||||
@@ -4489,7 +4486,6 @@
|
|||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -4867,7 +4863,6 @@
|
|||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
@@ -4881,7 +4876,6 @@
|
|||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
||||||
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
|
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"citty": "^0.1.6",
|
"citty": "^0.1.6",
|
||||||
@@ -5024,7 +5018,6 @@
|
|||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
@@ -5139,14 +5132,12 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
@@ -5172,7 +5163,6 @@
|
|||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"confbox": "^0.2.2",
|
"confbox": "^0.2.2",
|
||||||
@@ -5232,7 +5222,6 @@
|
|||||||
"version": "6.19.0",
|
"version": "6.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz",
|
||||||
"integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==",
|
"integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5280,7 +5269,6 @@
|
|||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -5318,7 +5306,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
@@ -5357,7 +5344,6 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 14.18.0"
|
||||||
@@ -5999,7 +5985,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"music-metadata": "^11.10.2",
|
"music-metadata": "^11.10.2",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^6.19.0",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -25,4 +27,4 @@
|
|||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
prisma/dev.db.bak
Normal file
BIN
prisma/dev.db.bak
Normal file
Binary file not shown.
@@ -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;
|
||||||
@@ -16,34 +16,56 @@ model Song {
|
|||||||
artist String
|
artist String
|
||||||
filename String // Filename in public/uploads
|
filename String // Filename in public/uploads
|
||||||
coverImage String? // Filename in public/uploads/covers
|
coverImage String? // Filename in public/uploads/covers
|
||||||
|
releaseYear Int? // Release year from iTunes
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
genres Genre[]
|
genres Genre[]
|
||||||
specials Special[]
|
specials SpecialSong[]
|
||||||
|
averageRating Float @default(0)
|
||||||
|
ratingCount Int @default(0)
|
||||||
|
excludeFromGlobal Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Genre {
|
model Genre {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
|
subtitle String?
|
||||||
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
maxAttempts Int @default(7)
|
subtitle String?
|
||||||
unlockSteps String // JSON array: "[2,4,7,11,16,30,60]"
|
maxAttempts Int @default(7)
|
||||||
createdAt DateTime @default(now())
|
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
|
||||||
songs Song[]
|
createdAt DateTime @default(now())
|
||||||
dailyPuzzles DailyPuzzle[]
|
launchDate DateTime?
|
||||||
|
endDate DateTime?
|
||||||
|
curator String?
|
||||||
|
songs SpecialSong[]
|
||||||
|
puzzles DailyPuzzle[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
model DailyPuzzle {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
date String // Format: YYYY-MM-DD
|
date String // Format: YYYY-MM-DD
|
||||||
songId Int
|
songId Int
|
||||||
song Song @relation(fields: [songId], references: [id])
|
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||||
genreId Int?
|
genreId Int?
|
||||||
genre Genre? @relation(fields: [genreId], references: [id])
|
genre Genre? @relation(fields: [genreId], references: [id])
|
||||||
specialId Int?
|
specialId Int?
|
||||||
|
|||||||
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."
|
||||||
@@ -3,10 +3,57 @@ set -e
|
|||||||
|
|
||||||
echo "🚀 Starting optimized deployment..."
|
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
|
# Pull latest changes
|
||||||
echo "📥 Pulling latest changes from git..."
|
echo "📥 Pulling latest changes from git..."
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
|
# Fetch all tags
|
||||||
|
echo "🏷️ Fetching git tags..."
|
||||||
|
git fetch --tags
|
||||||
|
|
||||||
# Build new image in background (doesn't stop running container)
|
# Build new image in background (doesn't stop running container)
|
||||||
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
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..."
|
echo "Starting deployment..."
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
echo "Starting application..."
|
echo "Starting application..."
|
||||||
exec node server.js
|
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 { PrismaClient } from '@prisma/client';
|
||||||
import { parseBuffer } from 'music-metadata';
|
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 path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
@@ -11,6 +11,16 @@ const __dirname = path.dirname(__filename);
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function migrate() {
|
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...');
|
console.log('Starting cover art migration...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -23,10 +33,18 @@ async function migrate() {
|
|||||||
|
|
||||||
console.log(`Found ${songs.length} songs without cover image.`);
|
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) {
|
for (const song of songs) {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||||
console.log(`Processing ${song.title} (${song.filename})...`);
|
|
||||||
|
|
||||||
const buffer = await readFile(filePath);
|
const buffer = await readFile(filePath);
|
||||||
const metadata = await parseBuffer(buffer);
|
const metadata = await parseBuffer(buffer);
|
||||||
@@ -47,16 +65,20 @@ async function migrate() {
|
|||||||
data: { coverImage: coverFilename }
|
data: { coverImage: coverFilename }
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ Extracted cover for ${song.title}`);
|
successful++;
|
||||||
} else {
|
|
||||||
console.log(`⚠️ No cover found for ${song.title}`);
|
|
||||||
}
|
}
|
||||||
|
processed++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`❌ Failed to process ${song.title}:`, e.message);
|
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) {
|
} catch (e) {
|
||||||
console.error('Migration failed:', e);
|
console.error('Migration failed:', e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -33,23 +33,33 @@ async function restoreSongs() {
|
|||||||
|
|
||||||
const title = metadata.common.title || 'Unknown Title';
|
const title = metadata.common.title || 'Unknown Title';
|
||||||
const artist = metadata.common.artist || 'Unknown Artist';
|
const artist = metadata.common.artist || 'Unknown Artist';
|
||||||
|
const genres = metadata.common.genre || [];
|
||||||
|
|
||||||
// Try to find matching cover
|
// Create or find genres
|
||||||
// This is a best-effort guess based on timestamp or just null if we can't link it easily
|
const genreConnect = [];
|
||||||
// Since we don't store the link between file and cover in filename, we might lose cover association
|
for (const genreName of genres) {
|
||||||
// unless we re-extract it. But we already have cover files.
|
if (!genreName) continue;
|
||||||
// For now, let's just restore the song entry. Re-extracting cover would duplicate files.
|
|
||||||
// If the user wants covers back perfectly, we might need to re-parse or just leave null.
|
// Simple normalization
|
||||||
// Let's leave null for now to avoid clutter, or maybe try to find a cover with similar timestamp if possible?
|
const normalizedGenre = genreName.trim();
|
||||||
// Actually, the cover filename is not easily deducible from song filename.
|
|
||||||
// Let's just restore the song data.
|
// 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({
|
await prisma.song.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
filename,
|
filename,
|
||||||
// coverImage: null // We lose the cover link unfortunately, unless we re-extract
|
genres: {
|
||||||
|
connect: genreConnect
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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();
|
||||||
Reference in New Issue
Block a user