Compare commits
52 Commits
0f7d66c619
...
v0.1.0.14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50511f11ac | ||
|
|
d69ac28bb3 | ||
|
|
7a65c58214 | ||
|
|
1a8177430d | ||
|
|
0ebb61515d | ||
|
|
dede11d22b | ||
|
|
4b96b95bff | ||
|
|
89fb296564 | ||
|
|
301dce4c97 | ||
|
|
b66bab48bd | ||
|
|
fea8384e60 | ||
|
|
de8813da3e | ||
|
|
0877842107 | ||
|
|
a5cbbffc20 | ||
|
|
ffb7be602f | ||
|
|
1d62aca2fb | ||
|
|
9bf7e72a6c | ||
|
|
f8b5dcf300 | ||
|
|
072158f4ed | ||
|
|
898d2f5959 | ||
|
|
a7aec80f39 | ||
|
|
0e313db2e3 | ||
|
|
3e647cd44b | ||
|
|
54af256e91 | ||
|
|
ce413cf6bc | ||
|
|
5102ca86cb | ||
|
|
eb3d2c86d7 | ||
|
|
883875b82a | ||
|
|
4c13817e77 | ||
|
|
35fe5f2d44 | ||
|
|
70501d626b | ||
|
|
41ce6c12ce | ||
|
|
a744393335 | ||
|
|
0ee3a48770 | ||
|
|
187774bce7 | ||
|
|
67cf85dc22 | ||
|
|
326023a705 | ||
|
|
41e2ec1495 | ||
|
|
62402d7000 | ||
|
|
0599c066d9 | ||
|
|
f7de7f2684 | ||
|
|
e5d06029ef | ||
|
|
e8e0aa27fb | ||
|
|
7f455053e7 | ||
|
|
3309b5c5ee | ||
|
|
cd30476349 | ||
|
|
cd19a6c04d | ||
|
|
7011a24b46 | ||
|
|
9a98830245 | ||
|
|
3630745169 | ||
|
|
831adcaf17 | ||
|
|
2d6481a42f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,3 +49,4 @@ next-env.d.ts
|
||||
!/public/uploads/.gitkeep
|
||||
/data
|
||||
.release-years-migrated
|
||||
.covers-migrated
|
||||
|
||||
185
DEBUG_VERSION.md
Normal file
185
DEBUG_VERSION.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Debug Version Display - Remote Server Checklist
|
||||
|
||||
## 1. Überprüfe Git-Tags auf dem Remote-Server
|
||||
|
||||
```bash
|
||||
# Im Projekt-Verzeichnis auf dem Remote-Server
|
||||
cd /path/to/hoerdle
|
||||
|
||||
# Zeige alle Tags
|
||||
git tag -l
|
||||
|
||||
# Zeige aktuellen Tag/Version
|
||||
git describe --tags --always
|
||||
|
||||
# Wenn keine Tags angezeigt werden:
|
||||
git fetch --tags
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Sollte `v0.1.0.2` oder ähnlich zeigen
|
||||
|
||||
---
|
||||
|
||||
## 2. Überprüfe die version.txt im Container
|
||||
|
||||
```bash
|
||||
# Zeige den Inhalt der Version-Datei im laufenden Container
|
||||
docker exec hoerdle cat /app/version.txt
|
||||
|
||||
# Sollte die Version zeigen, z.B. "v0.1.0.2"
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Die aktuelle Version, nicht "unknown" oder "dev"
|
||||
|
||||
---
|
||||
|
||||
## 3. Überprüfe die Umgebungsvariable im Container
|
||||
|
||||
```bash
|
||||
# Zeige alle Umgebungsvariablen
|
||||
docker exec hoerdle env | grep APP_VERSION
|
||||
|
||||
# Sollte APP_VERSION=v0.1.0.2 oder ähnlich zeigen
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** `APP_VERSION=v0.1.0.2`
|
||||
|
||||
---
|
||||
|
||||
## 4. Überprüfe die Container-Logs beim Start
|
||||
|
||||
```bash
|
||||
# Zeige die letzten Logs beim Container-Start
|
||||
docker logs hoerdle | head -20
|
||||
|
||||
# Suche speziell nach Version-Ausgaben
|
||||
docker logs hoerdle | grep -i version
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Eine Zeile wie "App version: v0.1.0.2"
|
||||
|
||||
---
|
||||
|
||||
## 5. Teste die API direkt
|
||||
|
||||
```bash
|
||||
# Rufe die Version-API auf
|
||||
curl http://localhost:3010/api/version
|
||||
|
||||
# Sollte JSON zurückgeben: {"version":"v0.1.0.2"}
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** `{"version":"v0.1.0.2"}`
|
||||
|
||||
---
|
||||
|
||||
## 6. Überprüfe wann der Container gebaut wurde
|
||||
|
||||
```bash
|
||||
# Zeige Image-Informationen
|
||||
docker images | grep hoerdle
|
||||
|
||||
# Zeige detaillierte Container-Informationen
|
||||
docker inspect hoerdle | grep -i created
|
||||
```
|
||||
|
||||
**Wichtig:** Wenn das Image vor deinem letzten Deployment erstellt wurde, wurde es noch nicht neu gebaut!
|
||||
|
||||
---
|
||||
|
||||
## 7. Überprüfe Build-Logs
|
||||
|
||||
```bash
|
||||
# Baue das Image neu und beobachte die Ausgabe
|
||||
docker compose build --no-cache 2>&1 | tee build.log
|
||||
|
||||
# Suche nach der Version-Ausgabe im Build
|
||||
grep -i "Building version" build.log
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Eine Zeile wie "Building version: v0.1.0.2"
|
||||
|
||||
---
|
||||
|
||||
## Häufige Probleme und Lösungen
|
||||
|
||||
### Problem 1: Tags nicht auf dem Server
|
||||
```bash
|
||||
git fetch --tags
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
### Problem 2: Container wurde nicht neu gebaut
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Problem 3: Alte version.txt im Container
|
||||
```bash
|
||||
# Stoppe Container, lösche Image, baue neu
|
||||
docker compose down
|
||||
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Problem 4: .git Verzeichnis nicht im Build-Context
|
||||
```bash
|
||||
# Überprüfe ob .git existiert
|
||||
ls -la .git
|
||||
|
||||
# Überprüfe .dockerignore (sollte .git NICHT ausschließen)
|
||||
cat .dockerignore 2>/dev/null || echo "Keine .dockerignore Datei"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vollständiger Neustart (wenn nichts anderes hilft)
|
||||
|
||||
```bash
|
||||
# 1. Stoppe alles
|
||||
docker compose down
|
||||
|
||||
# 2. Lösche alte Images
|
||||
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||
|
||||
# 3. Hole neueste Änderungen und Tags
|
||||
git pull
|
||||
git fetch --tags
|
||||
|
||||
# 4. Überprüfe Version lokal
|
||||
git describe --tags --always
|
||||
|
||||
# 5. Baue komplett neu
|
||||
docker compose build --no-cache
|
||||
|
||||
# 6. Starte Container
|
||||
docker compose up -d
|
||||
|
||||
# 7. Überprüfe Logs
|
||||
docker logs hoerdle | grep -i version
|
||||
|
||||
# 8. Teste API
|
||||
curl http://localhost:3010/api/version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging-Befehl für alle Checks auf einmal
|
||||
|
||||
```bash
|
||||
echo "=== Git Tags ===" && \
|
||||
git describe --tags --always && \
|
||||
echo -e "\n=== version.txt im Container ===" && \
|
||||
docker exec hoerdle cat /app/version.txt 2>/dev/null || echo "Container läuft nicht oder Datei fehlt" && \
|
||||
echo -e "\n=== APP_VERSION Env ===" && \
|
||||
docker exec hoerdle env | grep APP_VERSION || echo "Variable nicht gesetzt" && \
|
||||
echo -e "\n=== API Response ===" && \
|
||||
curl -s http://localhost:3010/api/version && \
|
||||
echo -e "\n\n=== Container Created ===" && \
|
||||
docker inspect hoerdle | grep -i created | head -1
|
||||
```
|
||||
|
||||
Kopiere diesen Befehl und führe ihn auf dem Remote-Server aus. Schicke mir die Ausgabe!
|
||||
84
DEPLOYMENT.md
Normal file
84
DEPLOYMENT.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Automated Deployment
|
||||
|
||||
Use the deployment script for zero-downtime deployments:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Create a database backup
|
||||
2. Pull latest changes from git
|
||||
3. Fetch all git tags (for version display)
|
||||
4. Build the new Docker image
|
||||
5. Restart the container with minimal downtime
|
||||
6. Clean up old images
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
If you need to deploy manually:
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Fetch tags (important for version display!)
|
||||
git fetch --tags
|
||||
|
||||
# Build and restart
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Version Display
|
||||
|
||||
The app displays the current version in the footer. The version is determined as follows:
|
||||
|
||||
1. **During Docker build**: The version is extracted from git tags using `git describe --tags --always`
|
||||
2. **At runtime**: The version is read from `/app/version.txt` and exposed via the `/api/version` endpoint
|
||||
3. **Local development**: The version is extracted directly from git on each request
|
||||
|
||||
### Building with a specific version
|
||||
|
||||
You can override the version during build:
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg APP_VERSION=v1.2.3
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If the version shows as "dev" or "unknown":
|
||||
|
||||
1. Make sure git tags are pushed to the remote repository:
|
||||
```bash
|
||||
git push --tags
|
||||
```
|
||||
|
||||
2. On the deployment server, fetch the tags:
|
||||
```bash
|
||||
git fetch --tags
|
||||
```
|
||||
|
||||
3. Verify tags are available:
|
||||
```bash
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
4. Rebuild the Docker image:
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The container includes a health check that monitors the `/api/daily` endpoint. Check the health status:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
|
||||
Look for the "healthy" status in the STATUS column.
|
||||
18
Dockerfile
18
Dockerfile
@@ -13,9 +13,24 @@ RUN npm ci
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Accept version as build argument (optional)
|
||||
ARG APP_VERSION=""
|
||||
|
||||
# Install git to extract version information
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Extract version: use build arg if provided, otherwise get from git
|
||||
RUN if [ -n "$APP_VERSION" ]; then \
|
||||
echo "$APP_VERSION" > /tmp/version.txt; \
|
||||
else \
|
||||
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
|
||||
fi && \
|
||||
echo "Building version: $(cat /tmp/version.txt)"
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
@@ -53,6 +68,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
# Create uploads directory and set permissions
|
||||
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
|
||||
|
||||
# Copy version file from builder
|
||||
COPY --from=builder /tmp/version.txt /app/version.txt
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
24
README.md
24
README.md
@@ -12,18 +12,22 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
|
||||
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
|
||||
- Bearbeitung von Metadaten.
|
||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am).
|
||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||
- **Cover Art:**
|
||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||
- Automatische Migration bestehender Songs.
|
||||
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden.
|
||||
- **Teilen-Funktion:**
|
||||
- Ergebnisse können als Emoji-Grid geteilt werden.
|
||||
- Stern-Symbol (⭐) bei korrekt beantworteter Bonusfrage.
|
||||
- Automatische Anpassung für Genre- und Special-Rätsel.
|
||||
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
||||
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
||||
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
||||
- **Genre-Management:**
|
||||
- Erstellen und Verwalten von Musik-Genres.
|
||||
- **Aktivierung/Deaktivierung:** Genres können aktiviert oder deaktiviert werden (deaktivierte Genres sind nicht auf der Startseite sichtbar und ihre Routen sind nicht erreichbar).
|
||||
- Manuelle Zuweisung von Genres zu Songs.
|
||||
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
||||
- Genre-spezifische tägliche Rätsel.
|
||||
@@ -37,7 +41,23 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Live-Vorschau beim Hovern über die Waveform.
|
||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Manuelle Speicherung mit visueller Bestätigung.
|
||||
- **News & Announcements:**
|
||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
||||
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||
- Verwaltung über das Admin-Dashboard.
|
||||
|
||||
## White Labeling
|
||||
|
||||
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
|
||||
|
||||
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)**
|
||||
|
||||
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
|
||||
|
||||
## Spielregeln & Punktesystem
|
||||
|
||||
|
||||
67
WHITE_LABEL.md
Normal file
67
WHITE_LABEL.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# White Labeling Guide
|
||||
|
||||
This application is designed to be easily white-labeled. You can customize the branding, colors, and configuration without modifying the core code.
|
||||
|
||||
## Configuration
|
||||
|
||||
The application is configured via environment variables. You can set these in a `.env` or `.env.local` file.
|
||||
|
||||
### Branding
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
|
||||
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
||||
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
|
||||
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
|
||||
|
||||
### Analytics (Plausible)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
|
||||
|
||||
### Credits
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_CREDITS_ENABLED` | Enable/disable footer credits (`true`/`false`). | `true` |
|
||||
| `NEXT_PUBLIC_CREDITS_TEXT` | Text before the link. | `Vibe coded with ☕ and 🍺 by` |
|
||||
| `NEXT_PUBLIC_CREDITS_LINK_TEXT` | Text of the link. | `@elpatron@digitalcourage.social` |
|
||||
| `NEXT_PUBLIC_CREDITS_LINK_URL` | URL of the link. | `https://digitalcourage.social/@elpatron` |
|
||||
|
||||
## Theming
|
||||
|
||||
The application uses CSS variables for theming. You can override these variables in your own CSS file or by modifying `app/globals.css`.
|
||||
|
||||
### Key Colors
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `--primary` | Main action color (buttons). | `#000000` |
|
||||
| `--secondary` | Secondary actions. | `#4b5563` |
|
||||
| `--accent` | Accent color. | `#667eea` |
|
||||
| `--success` | Success state (correct guess). | `#22c55e` |
|
||||
| `--danger` | Error state (wrong guess). | `#ef4444` |
|
||||
| `--warning` | Warning state (stars). | `#ffc107` |
|
||||
| `--muted` | Muted backgrounds. | `#f3f4f6` |
|
||||
|
||||
### Example: Red Theme
|
||||
|
||||
To create a red-themed version, add this to your CSS:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary: #dc2626;
|
||||
--accent: #ef4444;
|
||||
--accent-gradient: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
|
||||
}
|
||||
```
|
||||
|
||||
## Assets
|
||||
|
||||
To replace the logo and icons:
|
||||
1. Replace `public/favicon.ico`.
|
||||
2. Replace `public/icon.png` (if it exists).
|
||||
3. Update `app/manifest.ts` if you have custom icon paths.
|
||||
@@ -1,7 +1,9 @@
|
||||
import Game from '@/components/Game';
|
||||
import NewsSection from '@/components/NewsSection';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -14,8 +16,21 @@ interface PageProps {
|
||||
export default async function GenrePage({ params }: PageProps) {
|
||||
const { genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
// Check if genre exists and is active
|
||||
const currentGenre = await prisma.genre.findUnique({
|
||||
where: { name: decodedGenre }
|
||||
});
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const now = new Date();
|
||||
@@ -88,6 +103,7 @@ export default async function GenrePage({ params }: PageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NewsSection />
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||
</>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -63,6 +64,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { puzzleId } = await request.json();
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { rateLimit } from '@/lib/rateLimit';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Rate limiting: 5 login attempts per minute
|
||||
const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 5 });
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { password } = await request.json();
|
||||
// Default is hash for 'admin123'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
import { stat } from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function GET(
|
||||
@@ -8,26 +9,128 @@ export async function GET(
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
const filePath = path.join(process.cwd(), 'public/uploads', filename);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await stat(filePath);
|
||||
} catch {
|
||||
return new NextResponse('File not found', { status: 404 });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Read file
|
||||
const fileBuffer = await readFile(filePath);
|
||||
// Additional check: ensure no path separators
|
||||
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
|
||||
return new NextResponse('Invalid filename', { status: 400 });
|
||||
}
|
||||
|
||||
// Return with proper headers
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||
},
|
||||
});
|
||||
const filePath = path.join(process.cwd(), 'public/uploads', filename);
|
||||
|
||||
// Security: Verify the resolved path is still within uploads directory
|
||||
const uploadsDir = path.join(process.cwd(), 'public/uploads');
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(uploadsDir)) {
|
||||
return new NextResponse('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const stats = await stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
const range = request.headers.get('range');
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
|
||||
const stream = createReadStream(filePath, { start, end });
|
||||
|
||||
// Convert Node stream to Web stream
|
||||
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
let isClosed = false;
|
||||
|
||||
stream.on('data', (chunk: any) => {
|
||||
if (isClosed) return;
|
||||
try {
|
||||
controller.enqueue(chunk);
|
||||
} catch (e) {
|
||||
isClosed = true;
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
controller.close();
|
||||
});
|
||||
|
||||
stream.on('error', (err: any) => {
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return new NextResponse(readable, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize.toString(),
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const stream = createReadStream(filePath);
|
||||
|
||||
// Convert Node stream to Web stream
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
let isClosed = false;
|
||||
|
||||
stream.on('data', (chunk: any) => {
|
||||
if (isClosed) return;
|
||||
try {
|
||||
controller.enqueue(chunk);
|
||||
} catch (e) {
|
||||
isClosed = true;
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
controller.close();
|
||||
});
|
||||
|
||||
stream.on('error', (err: any) => {
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return new NextResponse(readable, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Length': fileSize.toString(),
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error serving audio file:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -16,6 +17,10 @@ interface CategorizeResult {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
return Response.json(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -21,8 +22,12 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { name, subtitle } = await request.json();
|
||||
const { name, subtitle, active } = await request.json();
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||
@@ -31,7 +36,8 @@ export async function POST(request: Request) {
|
||||
const genre = await prisma.genre.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
subtitle: subtitle ? subtitle.trim() : null
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
active: active !== undefined ? active : true
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,6 +49,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = await request.json();
|
||||
|
||||
@@ -62,8 +72,12 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id, name, subtitle } = await request.json();
|
||||
const { id, name, subtitle, active } = await request.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
@@ -73,7 +87,8 @@ export async function PUT(request: Request) {
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(name && { name: name.trim() }),
|
||||
subtitle: subtitle ? subtitle.trim() : null // Allow clearing subtitle if empty string passed? Or just update if provided? Let's assume null/empty string clears it.
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
...(active !== undefined && { active })
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
5
app/api/health/route.ts
Normal file
5
app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok' }, { status: 200 });
|
||||
}
|
||||
146
app/api/news/route.ts
Normal file
146
app/api/news/route.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET /api/news - Public endpoint to fetch news
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const featuredOnly = searchParams.get('featured') === 'true';
|
||||
|
||||
const where = featuredOnly ? { featured: true } : {};
|
||||
|
||||
const news = await prisma.news.findMany({
|
||||
where,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(news);
|
||||
} catch (error) {
|
||||
console.error('Error fetching news:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/news - Create news (requires auth)
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { title, content, author, featured, specialId } = body;
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title and content are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const news = await prisma.news.create({
|
||||
data: {
|
||||
title,
|
||||
content,
|
||||
author: author || null,
|
||||
featured: featured || false,
|
||||
specialId: specialId || null
|
||||
},
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(news, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating news:', error);
|
||||
return NextResponse.json({ error: 'Failed to create news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/news - Update news (requires auth)
|
||||
export async function PUT(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, title, content, author, featured, specialId } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (content !== undefined) updateData.content = content;
|
||||
if (author !== undefined) updateData.author = author || null;
|
||||
if (featured !== undefined) updateData.featured = featured;
|
||||
if (specialId !== undefined) updateData.specialId = specialId || null;
|
||||
|
||||
const news = await prisma.news.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(news);
|
||||
} catch (error) {
|
||||
console.error('Error updating news:', error);
|
||||
return NextResponse.json({ error: 'Failed to update news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/news - Delete news (requires auth)
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.news.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting news:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@ import { writeFile, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { parseBuffer } from 'music-metadata';
|
||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Configure route to handle large file uploads
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||
|
||||
export async function GET() {
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -36,23 +41,63 @@ export async function GET() {
|
||||
specials: song.specials.map(ss => ss.special),
|
||||
averageRating: song.averageRating,
|
||||
ratingCount: song.ratingCount,
|
||||
excludeFromGlobal: song.excludeFromGlobal,
|
||||
}));
|
||||
|
||||
return NextResponse.json(songsWithActivations);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
console.log('[UPLOAD] Starting song upload request');
|
||||
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
console.log('[UPLOAD] Authentication failed');
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[UPLOAD] Parsing form data...');
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
let title = '';
|
||||
let artist = '';
|
||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
|
||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
||||
|
||||
if (!file) {
|
||||
console.error('[UPLOAD] No file provided');
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Security: Validate file size (max 50MB)
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({
|
||||
error: `File too large. Maximum size is 50MB, got ${(file.size / 1024 / 1024).toFixed(2)}MB`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Security: Validate MIME type
|
||||
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3'];
|
||||
if (!allowedMimeTypes.includes(file.type)) {
|
||||
return NextResponse.json({
|
||||
error: `Invalid file type. Expected MP3, got ${file.type}`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Security: Validate file extension
|
||||
if (!file.name.toLowerCase().endsWith('.mp3')) {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid file extension. Only .mp3 files are allowed'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
|
||||
|
||||
// Validate and extract metadata from file
|
||||
let metadata;
|
||||
@@ -180,16 +225,17 @@ export async function POST(request: Request) {
|
||||
console.error('Failed to extract cover image:', e);
|
||||
}
|
||||
|
||||
// Fetch release year from MusicBrainz
|
||||
// Fetch release year from iTunes
|
||||
let releaseYear = null;
|
||||
try {
|
||||
const { getReleaseYear } = await import('@/lib/musicbrainz');
|
||||
releaseYear = await getReleaseYear(artist, title);
|
||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||
releaseYear = await getReleaseYearFromItunes(artist, title);
|
||||
|
||||
if (releaseYear) {
|
||||
console.log(`Fetched release year ${releaseYear} for "${title}" by "${artist}"`);
|
||||
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch release year from MusicBrainz:', e);
|
||||
console.error('Failed to fetch release year:', e);
|
||||
}
|
||||
|
||||
const song = await prisma.song.create({
|
||||
@@ -199,6 +245,7 @@ export async function POST(request: Request) {
|
||||
filename,
|
||||
coverImage,
|
||||
releaseYear,
|
||||
excludeFromGlobal,
|
||||
},
|
||||
include: { genres: true, specials: true }
|
||||
});
|
||||
@@ -214,8 +261,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json();
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||
|
||||
if (!id || !title || !artist) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||
@@ -228,6 +279,10 @@ export async function PUT(request: Request) {
|
||||
data.releaseYear = releaseYear;
|
||||
}
|
||||
|
||||
if (excludeFromGlobal !== undefined) {
|
||||
data.excludeFromGlobal = excludeFromGlobal;
|
||||
}
|
||||
|
||||
if (genreIds) {
|
||||
data.genres = {
|
||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||
@@ -289,6 +344,10 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = await request.json();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PrismaClient, Special } from '@prisma/client';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -16,6 +17,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||
@@ -35,6 +40,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id } = await request.json();
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||
@@ -44,6 +53,10 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,24 @@
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
|
||||
/* Theme Colors */
|
||||
--primary: #000000;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #4b5563;
|
||||
--secondary-foreground: #ffffff;
|
||||
--accent: #667eea;
|
||||
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--success: #22c55e;
|
||||
--success-foreground: #ffffff;
|
||||
--danger: #ef4444;
|
||||
--danger-foreground: #ffffff;
|
||||
--warning: #ffc107;
|
||||
--muted: #f3f4f6;
|
||||
--muted-foreground: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--input: #d1d5db;
|
||||
--ring: #000000;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -51,13 +69,13 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Audio Player */
|
||||
.audio-player {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
@@ -73,8 +91,8 @@ body {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -85,19 +103,20 @@ body {
|
||||
|
||||
.play-button:hover {
|
||||
background: #333;
|
||||
/* Keep for now or add --primary-hover */
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
flex: 1;
|
||||
height: 0.5rem;
|
||||
background: #d1d5db;
|
||||
background: var(--input);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #22c55e;
|
||||
background: var(--success);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
@@ -114,7 +133,7 @@ body {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -125,7 +144,7 @@ body {
|
||||
}
|
||||
|
||||
.guess-text {
|
||||
color: #ef4444;
|
||||
color: var(--danger);
|
||||
/* Red for wrong */
|
||||
}
|
||||
|
||||
@@ -135,7 +154,7 @@ body {
|
||||
}
|
||||
|
||||
.guess-text.correct {
|
||||
color: #22c55e;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Input */
|
||||
@@ -148,14 +167,14 @@ body {
|
||||
.guess-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.guess-input:focus {
|
||||
outline: 2px solid #000;
|
||||
outline: 2px solid var(--ring);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@@ -163,7 +182,7 @@ body {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
max-height: 15rem;
|
||||
@@ -177,11 +196,11 @@ body {
|
||||
.suggestion-item {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
border-bottom: 1px solid var(--muted);
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
@@ -190,14 +209,14 @@ body {
|
||||
|
||||
.suggestion-artist {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.skip-button {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-top: 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -246,7 +265,7 @@ body {
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@@ -265,14 +284,14 @@ body {
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -292,8 +311,8 @@ body {
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #4b5563;
|
||||
color: #fff;
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -312,8 +331,8 @@ body {
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
color: var(--danger-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -337,8 +356,8 @@ body {
|
||||
padding: 2rem 1rem 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: var(--muted-foreground);
|
||||
border-top: 1px solid var(--border);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -347,7 +366,7 @@ body {
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
color: #000;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -375,7 +394,7 @@ body {
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
margin: 0 0 1rem 0;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.statistics-grid {
|
||||
@@ -391,7 +410,7 @@ body {
|
||||
padding: 0.75rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
@@ -401,7 +420,7 @@ body {
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -409,7 +428,7 @@ body {
|
||||
.stat-count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
@@ -13,18 +16,19 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hördle",
|
||||
description: "Daily music guessing game - Guess the song from short audio clips",
|
||||
title: config.appName,
|
||||
description: config.appDescription,
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#000000",
|
||||
themeColor: config.colors.themeColor,
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -33,18 +37,18 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
data-domain={config.plausibleDomain}
|
||||
src={config.plausibleScriptSrc}
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Vibe coded with ☕ and 🍺 by{' '}
|
||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||
@elpatron@digitalcourage.social
|
||||
</a>
|
||||
{' '}- for personal use among friends only!
|
||||
</p>
|
||||
</footer>
|
||||
<AppFooter />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Hördle',
|
||||
short_name: 'Hördle',
|
||||
description: 'Daily music guessing game - Guess the song from short audio clips',
|
||||
name: config.appName,
|
||||
short_name: config.appName,
|
||||
description: config.appDescription,
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#000000',
|
||||
background_color: config.colors.backgroundColor,
|
||||
theme_color: config.colors.themeColor,
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.ico',
|
||||
|
||||
15
app/page.tsx
15
app/page.tsx
@@ -1,4 +1,6 @@
|
||||
import Game from '@/components/Game';
|
||||
import NewsSection from '@/components/NewsSection';
|
||||
import OnboardingTour from '@/components/OnboardingTour';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
@@ -9,7 +11,10 @@ const prisma = new PrismaClient();
|
||||
|
||||
export default async function Home() {
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const now = new Date();
|
||||
@@ -26,7 +31,7 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div className="tooltip">
|
||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
||||
@@ -90,7 +95,13 @@ export default async function Home() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="tour-news">
|
||||
<NewsSection />
|
||||
</div>
|
||||
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||
<OnboardingTour />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Game from '@/components/Game';
|
||||
import NewsSection from '@/components/NewsSection';
|
||||
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
@@ -44,7 +45,10 @@ export default async function SpecialPage({ params }: PageProps) {
|
||||
}
|
||||
|
||||
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
|
||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const activeSpecials = specials.filter(s => {
|
||||
@@ -94,6 +98,7 @@ export default async function SpecialPage({ params }: PageProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<NewsSection />
|
||||
<Game
|
||||
dailyPuzzle={dailyPuzzle}
|
||||
genre={decodedName}
|
||||
|
||||
36
components/AppFooter.tsx
Normal file
36
components/AppFooter.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { config } from '@/lib/config';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function AppFooter() {
|
||||
const [version, setVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/version')
|
||||
.then(res => res.json())
|
||||
.then(data => setVersion(data.version))
|
||||
.catch(() => setVersion(''));
|
||||
}, []);
|
||||
|
||||
if (!config.credits.enabled) return null;
|
||||
|
||||
return (
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
{config.credits.text}{' '}
|
||||
<a href={config.credits.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{config.credits.linkText}
|
||||
</a>
|
||||
{version && (
|
||||
<>
|
||||
{' '}·{' '}
|
||||
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
|
||||
{version}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
@@ -9,39 +9,112 @@ interface AudioPlayerProps {
|
||||
onPlay?: () => void;
|
||||
onReplay?: () => void;
|
||||
autoPlay?: boolean;
|
||||
onHasPlayedChange?: (hasPlayed: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
|
||||
export interface AudioPlayerRef {
|
||||
play: () => void;
|
||||
}
|
||||
|
||||
const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false, onHasPlayedChange }, ref) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
||||
|
||||
const [processedSrc, setProcessedSrc] = useState(src);
|
||||
const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState(unlockedSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[AudioPlayer] MOUNTED');
|
||||
return () => console.log('[AudioPlayer] UNMOUNTED');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = startTime;
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
setHasPlayedOnce(false); // Reset for new segment
|
||||
// Check if props changed compared to what we last processed
|
||||
const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
|
||||
|
||||
if (autoPlay) {
|
||||
const playPromise = audioRef.current.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
onPlay?.();
|
||||
setHasPlayedOnce(true);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log("Autoplay prevented:", error);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
if (hasChanged) {
|
||||
audioRef.current.pause();
|
||||
|
||||
let startPos = startTime;
|
||||
|
||||
// If same song but more time unlocked, start from where previous segment ended
|
||||
if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
|
||||
startPos = startTime + processedUnlockedSeconds;
|
||||
}
|
||||
|
||||
const targetPos = startPos;
|
||||
audioRef.current.currentTime = targetPos;
|
||||
|
||||
// Ensure position is set correctly even if browser resets it
|
||||
setTimeout(() => {
|
||||
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
|
||||
audioRef.current.currentTime = targetPos;
|
||||
}
|
||||
}, 50);
|
||||
|
||||
setIsPlaying(false);
|
||||
|
||||
// Calculate initial progress
|
||||
const initialElapsed = startPos - startTime;
|
||||
const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0;
|
||||
setProgress(Math.min(initialPercent, 100));
|
||||
|
||||
setHasPlayedOnce(false); // Reset for new segment
|
||||
onHasPlayedChange?.(false); // Notify parent
|
||||
|
||||
// Update processed state
|
||||
setProcessedSrc(src);
|
||||
setProcessedUnlockedSeconds(unlockedSeconds);
|
||||
|
||||
if (autoPlay) {
|
||||
// Delay play slightly to ensure currentTime sticks
|
||||
setTimeout(() => {
|
||||
const playPromise = audioRef.current?.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
onPlay?.();
|
||||
setHasPlayedOnce(true);
|
||||
onHasPlayedChange?.(true); // Notify parent
|
||||
})
|
||||
.catch(error => {
|
||||
console.log("Autoplay prevented:", error);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [src, unlockedSeconds, startTime, autoPlay]);
|
||||
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
|
||||
|
||||
// Expose play method to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
const playPromise = audioRef.current.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
onPlay?.();
|
||||
if (!hasPlayedOnce) {
|
||||
setHasPlayedOnce(true);
|
||||
onHasPlayedChange?.(true);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Play failed:", error);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return;
|
||||
@@ -56,6 +129,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
onReplay?.();
|
||||
} else {
|
||||
setHasPlayedOnce(true);
|
||||
onHasPlayedChange?.(true); // Notify parent
|
||||
}
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
@@ -112,4 +186,10 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
AudioPlayer.displayName = 'AudioPlayer';
|
||||
|
||||
|
||||
|
||||
export default AudioPlayer;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
import { config } from '@/lib/config';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||
import GuessInput from './GuessInput';
|
||||
import Statistics from './Statistics';
|
||||
import { useGameState } from '../lib/gameState';
|
||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||
|
||||
// Plausible Analytics
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||
}
|
||||
}
|
||||
|
||||
interface GameProps {
|
||||
dailyPuzzle: {
|
||||
id: number;
|
||||
@@ -37,6 +45,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||
const [hasRated, setHasRated] = useState(false);
|
||||
const [showYearModal, setShowYearModal] = useState(false);
|
||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -74,7 +84,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
useEffect(() => {
|
||||
if (dailyPuzzle) {
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
|
||||
if (ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||
setHasRated(true);
|
||||
} else {
|
||||
@@ -101,6 +111,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (song.id === dailyPuzzle.songId) {
|
||||
addGuess(song.title, true);
|
||||
setHasWon(true);
|
||||
// Track puzzle solved event
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: gameState.guesses.length + 1,
|
||||
score: gameState.score + 20, // Include the win bonus
|
||||
outcome: 'won'
|
||||
}
|
||||
});
|
||||
}
|
||||
// Notification sent after year guess or skip
|
||||
if (!dailyPuzzle.releaseYear) {
|
||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
@@ -110,19 +131,54 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
// Track puzzle lost event
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: maxAttempts,
|
||||
score: 0,
|
||||
outcome: 'lost'
|
||||
}
|
||||
});
|
||||
}
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||
}
|
||||
}
|
||||
setTimeout(() => setIsProcessingGuess(false), 500);
|
||||
};
|
||||
|
||||
const handleStartAudio = () => {
|
||||
// This will be called when user clicks "Start" button on first attempt
|
||||
// Trigger the audio player to start playing
|
||||
audioPlayerRef.current?.play();
|
||||
setHasPlayedAudio(true);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// If user hasn't played audio yet on first attempt, start it instead of skipping
|
||||
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||||
handleStartAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
// Track puzzle lost event
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: maxAttempts,
|
||||
score: 0,
|
||||
outcome: 'lost'
|
||||
}
|
||||
});
|
||||
}
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||
}
|
||||
};
|
||||
@@ -133,6 +189,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
// Track puzzle lost event
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: gameState.guesses.length + 1,
|
||||
score: 0,
|
||||
outcome: 'lost'
|
||||
}
|
||||
});
|
||||
}
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||
};
|
||||
|
||||
@@ -141,6 +208,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
addYearBonus(correct);
|
||||
setShowYearModal(false);
|
||||
|
||||
// Update the puzzle_solved event with year bonus result
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: gameState.guesses.length,
|
||||
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
|
||||
outcome: 'won',
|
||||
year_bonus: correct ? 'correct' : 'incorrect'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||
};
|
||||
@@ -148,6 +228,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const handleYearSkip = () => {
|
||||
skipYearBonus();
|
||||
setShowYearModal(false);
|
||||
|
||||
// Update the puzzle_solved event with year bonus result
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('puzzle_solved', {
|
||||
props: {
|
||||
genre: genre || 'Global',
|
||||
attempts: gameState.guesses.length,
|
||||
score: gameState.score, // Score already includes win bonus
|
||||
outcome: 'won',
|
||||
year_bonus: 'skipped'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
@@ -173,9 +267,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
|
||||
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` : '';
|
||||
|
||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||
let shareUrl = `https://${config.domain}`;
|
||||
if (genre) {
|
||||
if (isSpecial) {
|
||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||||
@@ -184,7 +279,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}
|
||||
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
|
||||
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
@@ -222,10 +317,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||
setHasRated(true);
|
||||
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
|
||||
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||
ratedPuzzles.push(dailyPuzzle.id);
|
||||
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles));
|
||||
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit rating', error);
|
||||
@@ -235,28 +330,34 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="header">
|
||||
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '-0.5rem', marginBottom: '1rem' }}>
|
||||
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||
Next puzzle in: {timeUntilNext}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="game-board">
|
||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||
<div className="status-bar">
|
||||
<div id="tour-status" className="status-bar">
|
||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s unlocked</span>
|
||||
</div>
|
||||
|
||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||
<div id="tour-score">
|
||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||
</div>
|
||||
|
||||
<AudioPlayer
|
||||
src={dailyPuzzle.audioUrl}
|
||||
unlockedSeconds={unlockedSeconds}
|
||||
startTime={dailyPuzzle.startTime}
|
||||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||
onReplay={addReplay}
|
||||
/>
|
||||
<div id="tour-player">
|
||||
<AudioPlayer
|
||||
ref={audioPlayerRef}
|
||||
src={dailyPuzzle.audioUrl}
|
||||
unlockedSeconds={unlockedSeconds}
|
||||
startTime={dailyPuzzle.startTime}
|
||||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||
onReplay={addReplay}
|
||||
onHasPlayedChange={setHasPlayedAudio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="guess-list">
|
||||
@@ -275,13 +376,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
{!hasWon && !hasLost && (
|
||||
<>
|
||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||
<div id="tour-input">
|
||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||
</div>
|
||||
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||
<button
|
||||
id="tour-controls"
|
||||
onClick={handleSkip}
|
||||
className="skip-button"
|
||||
>
|
||||
Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
|
||||
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||||
? 'Start'
|
||||
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -304,11 +411,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
{hasWon ? 'You won!' : 'Game Over'}
|
||||
</h2>
|
||||
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
|
||||
Score: {gameState.score}
|
||||
</div>
|
||||
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
|
||||
<summary>Score Breakdown</summary>
|
||||
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||
{gameState.scoreBreakdown.map((item, i) => (
|
||||
@@ -331,9 +438,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
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 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
{dailyPuzzle.releaseYear && (
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||
)}
|
||||
<audio controls style={{ width: '100%' }}>
|
||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||
@@ -384,13 +491,13 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
||||
textAlign: 'center',
|
||||
margin: '0.5rem 0',
|
||||
padding: '0.5rem',
|
||||
background: '#f3f4f6',
|
||||
background: 'var(--muted)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'monospace',
|
||||
cursor: 'help'
|
||||
}}>
|
||||
<span style={{ color: '#666' }}>{expression} = </span>
|
||||
<span style={{ color: 'var(--muted-foreground)' }}>{expression} = </span>
|
||||
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -398,6 +505,7 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
||||
|
||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||
const [options, setOptions] = useState<number[]>([]);
|
||||
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
|
||||
|
||||
useEffect(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -426,6 +534,24 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||
}, [correctYear]);
|
||||
|
||||
const handleGuess = (year: number) => {
|
||||
const correct = year === correctYear;
|
||||
setFeedback({ show: true, correct, guessedYear: year });
|
||||
|
||||
// Close modal after showing feedback
|
||||
setTimeout(() => {
|
||||
onGuess(year);
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setFeedback({ show: true, correct: false });
|
||||
|
||||
setTimeout(() => {
|
||||
onSkip();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
@@ -449,51 +575,81 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
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>
|
||||
{!feedback.show ? (
|
||||
<>
|
||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>Bonus Round!</h3>
|
||||
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>Guess the release year for <strong style={{ color: 'var(--success)' }}>+10 points</strong>!</p>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
{options.map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => handleGuess(year)}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: 'var(--muted)',
|
||||
border: '2px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--secondary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: '#f3f4f6',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--muted-foreground)',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
>
|
||||
{year}
|
||||
Skip Bonus
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Skip Bonus
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
{feedback.guessedYear ? (
|
||||
feedback.correct ? (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>Correct!</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
|
||||
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 Points!</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>Not quite!</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>You guessed {feedback.guessedYear}</p>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>Actually released in <strong>{correctYear}</strong></p>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>⏭️</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>Skipped</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>Released in {correctYear}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -504,12 +660,12 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
const [rating, setRating] = useState(0);
|
||||
|
||||
if (hasRated) {
|
||||
return <div style={{ color: '#666', fontStyle: 'italic' }}>Thanks for rating!</div>;
|
||||
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>Thanks for rating!</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: '#666', fontWeight: '500' }}>Rate this puzzle:</span>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>Rate this puzzle:</span>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||
{[...Array(5)].map((_, index) => {
|
||||
const ratingValue = index + 1;
|
||||
@@ -522,7 +678,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '2rem',
|
||||
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
|
||||
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
|
||||
transition: 'color 0.2s',
|
||||
padding: '0 0.25rem'
|
||||
}}
|
||||
|
||||
199
components/NewsSection.tsx
Normal file
199
components/NewsSection.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface NewsItem {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string | null;
|
||||
publishedAt: string;
|
||||
featured: boolean;
|
||||
special: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function NewsSection() {
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNews();
|
||||
}, []);
|
||||
|
||||
const fetchNews = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/news?limit=3');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNews(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch news:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || news.length === 0) {
|
||||
return null; // Don't show anything if no news
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#f9fafb',
|
||||
borderRadius: '0.5rem',
|
||||
margin: '1rem auto',
|
||||
maxWidth: '800px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}
|
||||
>
|
||||
<span>📰 News & Updates</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div style={{
|
||||
padding: '0 1rem 1rem 1rem',
|
||||
borderTop: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{news.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: index < news.length - 1 ? '1px solid #e5e7eb' : 'none'
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.25rem'
|
||||
}}>
|
||||
{item.featured && (
|
||||
<span style={{
|
||||
background: '#fef3c7',
|
||||
color: '#92400e',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
⭐ FEATURED
|
||||
</span>
|
||||
)}
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: '#111827'
|
||||
}}>
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#6b7280',
|
||||
marginBottom: '0.5rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<span>
|
||||
{new Date(item.publishedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
{item.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>by {item.author}</span>
|
||||
</>
|
||||
)}
|
||||
{item.special && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Link
|
||||
href={`/special/${item.special.name}`}
|
||||
style={{
|
||||
color: '#be185d',
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {item.special.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="news-content"
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
color: '#374151',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p style={{ margin: '0.5rem 0' }}>{children}</p>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#4f46e5', textDecoration: 'underline' }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => <strong style={{ fontWeight: '600' }}>{children}</strong>,
|
||||
em: ({ children }) => <em style={{ fontStyle: 'italic' }}>{children}</em>,
|
||||
ul: ({ children }) => <ul style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ul>,
|
||||
ol: ({ children }) => <ol style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ol>,
|
||||
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
||||
}}
|
||||
>
|
||||
{item.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
components/OnboardingTour.tsx
Normal file
109
components/OnboardingTour.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { driver } from 'driver.js';
|
||||
import 'driver.js/dist/driver.css';
|
||||
|
||||
export default function OnboardingTour() {
|
||||
useEffect(() => {
|
||||
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
|
||||
|
||||
if (hasCompletedOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const driverObj = driver({
|
||||
showProgress: true,
|
||||
animate: true,
|
||||
allowClose: true,
|
||||
doneBtnText: 'Done',
|
||||
nextBtnText: 'Next',
|
||||
prevBtnText: 'Previous',
|
||||
onDestroyed: () => {
|
||||
localStorage.setItem('hoerdle_onboarding_completed', 'true');
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
element: '#tour-genres',
|
||||
popover: {
|
||||
title: 'Genres & Specials',
|
||||
description: 'Choose a specific genre or a curated special event here.',
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-news',
|
||||
popover: {
|
||||
title: 'News',
|
||||
description: 'Stay updated with the latest news and announcements.',
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-title',
|
||||
popover: {
|
||||
title: 'Hördle',
|
||||
description: 'This is the daily puzzle. One new song every day per genre.',
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-status',
|
||||
popover: {
|
||||
title: 'Attempts',
|
||||
description: 'You have a limited number of attempts to guess the song.',
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-score',
|
||||
popover: {
|
||||
title: 'Score',
|
||||
description: 'Your current score. Try to keep it high!',
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-player',
|
||||
popover: {
|
||||
title: 'Player',
|
||||
description: 'Listen to the snippet. Each additional play reduces your potential score.',
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-input',
|
||||
popover: {
|
||||
title: 'Input',
|
||||
description: 'Type your guess here. Search for artist or title.',
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#tour-controls',
|
||||
popover: {
|
||||
title: 'Controls',
|
||||
description: 'Start the music or skip to the next snippet if you\'re stuck.',
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
driverObj.drive();
|
||||
}, 1000);
|
||||
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -26,4 +26,4 @@ services:
|
||||
start_period: 40s
|
||||
# Run migrations and start server (auto-baseline on first run if needed)
|
||||
command: >
|
||||
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node scripts/migrate-release-years.mjs && 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);
|
||||
}
|
||||
18
lib/config.ts
Normal file
18
lib/config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const config = {
|
||||
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
||||
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
|
||||
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
|
||||
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
|
||||
colors: {
|
||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
||||
},
|
||||
credits: {
|
||||
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
||||
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
||||
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||
}
|
||||
};
|
||||
@@ -33,7 +33,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
// Get songs available for this genre
|
||||
const whereClause = genreId
|
||||
? { genres: { some: { id: genreId } } }
|
||||
: {}; // Global puzzle picks from ALL songs
|
||||
: { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
|
||||
|
||||
const allSongs = await prisma.song.findMany({
|
||||
where: whereClause,
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* MusicBrainz API integration for fetching release years
|
||||
* API Documentation: https://musicbrainz.org/doc/MusicBrainz_API
|
||||
* Rate Limiting: 50 requests per second for meaningful User-Agent strings
|
||||
*/
|
||||
|
||||
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
|
||||
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
|
||||
const RATE_LIMIT_DELAY = 25; // 25ms between requests = ~40 req/s (safe margin)
|
||||
|
||||
/**
|
||||
* Sleep utility for rate limiting
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with retry logic for HTTP 503 (rate limit exceeded)
|
||||
*/
|
||||
async function fetchWithRetry(url: string, maxRetries = 5): Promise<Response> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// If rate limited (503), wait with exponential backoff
|
||||
if (response.status === 503) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
|
||||
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
||||
await sleep(waitTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
if (attempt < maxRetries - 1) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000;
|
||||
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
||||
await sleep(waitTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the earliest release year for a song from MusicBrainz
|
||||
* @param artist Artist name
|
||||
* @param title Song title
|
||||
* @returns Release year or null if not found
|
||||
*/
|
||||
export async function getReleaseYear(artist: string, title: string): Promise<number | null> {
|
||||
try {
|
||||
// Build search query using Lucene syntax
|
||||
const query = `artist:"${artist}" AND recording:"${title}"`;
|
||||
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
|
||||
|
||||
// Add rate limiting delay
|
||||
await sleep(RATE_LIMIT_DELAY);
|
||||
|
||||
const response = await fetchWithRetry(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.recordings || data.recordings.length === 0) {
|
||||
console.log(`No recordings found for "${title}" by "${artist}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the earliest release year from all recordings
|
||||
let earliestYear: number | null = null;
|
||||
|
||||
for (const recording of data.recordings) {
|
||||
// Check if recording has releases
|
||||
if (recording.releases && recording.releases.length > 0) {
|
||||
for (const release of recording.releases) {
|
||||
if (release.date) {
|
||||
// Extract year from date (format: YYYY-MM-DD or YYYY)
|
||||
const year = parseInt(release.date.split('-')[0]);
|
||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
||||
earliestYear = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check first-release-date on the recording itself
|
||||
if (recording['first-release-date']) {
|
||||
const year = parseInt(recording['first-release-date'].split('-')[0]);
|
||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
||||
earliestYear = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (earliestYear) {
|
||||
console.log(`Found release year ${earliestYear} for "${title}" by "${artist}"`);
|
||||
} else {
|
||||
console.log(`No release year found for "${title}" by "${artist}"`);
|
||||
}
|
||||
|
||||
return earliestYear;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching release year for "${title}" by "${artist}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
76
lib/rateLimit.ts
Normal file
76
lib/rateLimit.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Rate limiting configuration
|
||||
* Simple in-memory rate limiter for API endpoints
|
||||
*/
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
const rateLimitMap = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Clean up old entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of rateLimitMap.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
rateLimitMap.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
export interface RateLimitConfig {
|
||||
windowMs: number; // Time window in milliseconds
|
||||
maxRequests: number; // Maximum requests per window
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting middleware
|
||||
* @param request - The incoming request
|
||||
* @param config - Rate limit configuration
|
||||
* @returns NextResponse with 429 status if rate limit exceeded, null otherwise
|
||||
*/
|
||||
export function rateLimit(
|
||||
request: NextRequest,
|
||||
config: RateLimitConfig = { windowMs: 60000, maxRequests: 100 }
|
||||
): NextResponse | null {
|
||||
// Get client identifier (IP address or fallback)
|
||||
const identifier =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(identifier);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
// Create new entry or reset expired entry
|
||||
rateLimitMap.set(identifier, {
|
||||
count: 1,
|
||||
resetTime: now + config.windowMs
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.count >= config.maxRequests) {
|
||||
const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': retryAfter.toString(),
|
||||
'X-RateLimit-Limit': config.maxRequests.toString(),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'X-RateLimit-Reset': new Date(entry.resetTime).toISOString()
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
entry.count++;
|
||||
return null;
|
||||
}
|
||||
52
middleware.ts
Normal file
52
middleware.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next();
|
||||
|
||||
// Security Headers
|
||||
const headers = response.headers;
|
||||
|
||||
// Prevent clickjacking
|
||||
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
||||
|
||||
// XSS Protection (legacy but still useful)
|
||||
headers.set('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Referrer Policy
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Permissions Policy (restrict features)
|
||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
// Content Security Policy
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me", // Next.js requires unsafe-inline/eval
|
||||
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
||||
"media-src 'self' blob:",
|
||||
"frame-ancestors 'self'",
|
||||
].join('; ');
|
||||
headers.set('Content-Security-Policy', csp);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Apply middleware to all routes
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
middlewareClientMaxBodySize: '50mb',
|
||||
},
|
||||
env: {
|
||||
TZ: process.env.TZ || 'Europe/Berlin',
|
||||
|
||||
1187
package-lock.json
generated
1187
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.0.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -11,11 +11,13 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"music-metadata": "^11.10.2",
|
||||
"next": "16.0.3",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"react-markdown": "^10.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -27,4 +29,4 @@
|
||||
"eslint-config-next": "16.0.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
prisma/dev.db.bak
Normal file
BIN
prisma/dev.db.bak
Normal file
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Song" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"artist" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"coverImage" TEXT,
|
||||
"releaseYear" INTEGER,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"averageRating" REAL NOT NULL DEFAULT 0,
|
||||
"ratingCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"excludeFromGlobal" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
INSERT INTO "new_Song" ("artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title") SELECT "artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title" FROM "Song";
|
||||
DROP TABLE "Song";
|
||||
ALTER TABLE "new_Song" RENAME TO "Song";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Genre" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"subtitle" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
INSERT INTO "new_Genre" ("id", "name", "subtitle") SELECT "id", "name", "subtitle" FROM "Genre";
|
||||
DROP TABLE "Genre";
|
||||
ALTER TABLE "new_Genre" RENAME TO "Genre";
|
||||
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "News" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"author" TEXT,
|
||||
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"featured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"specialId" INTEGER,
|
||||
CONSTRAINT "News_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "News_publishedAt_idx" ON "News"("publishedAt");
|
||||
@@ -16,19 +16,21 @@ model Song {
|
||||
artist String
|
||||
filename String // Filename in public/uploads
|
||||
coverImage String? // Filename in public/uploads/covers
|
||||
releaseYear Int? // Release year from MusicBrainz
|
||||
releaseYear Int? // Release year from iTunes
|
||||
createdAt DateTime @default(now())
|
||||
puzzles DailyPuzzle[]
|
||||
genres Genre[]
|
||||
specials SpecialSong[]
|
||||
averageRating Float @default(0)
|
||||
ratingCount Int @default(0)
|
||||
excludeFromGlobal Boolean @default(false)
|
||||
}
|
||||
|
||||
model Genre {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
subtitle String?
|
||||
active Boolean @default(true)
|
||||
songs Song[]
|
||||
dailyPuzzles DailyPuzzle[]
|
||||
}
|
||||
@@ -45,6 +47,7 @@ model Special {
|
||||
curator String?
|
||||
songs SpecialSong[]
|
||||
puzzles DailyPuzzle[]
|
||||
news News[]
|
||||
}
|
||||
|
||||
model SpecialSong {
|
||||
@@ -71,3 +74,17 @@ model DailyPuzzle {
|
||||
|
||||
@@unique([date, genreId, specialId])
|
||||
}
|
||||
|
||||
model News {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
content String // Markdown format
|
||||
author String? // Optional: curator/admin name
|
||||
publishedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
featured Boolean @default(false) // Highlight important news
|
||||
specialId Int? // Optional: link to a special
|
||||
special Special? @relation(fields: [specialId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([publishedAt])
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ fi
|
||||
echo "📥 Pulling latest changes from git..."
|
||||
git pull
|
||||
|
||||
# Fetch all tags
|
||||
echo "🏷️ Fetching git tags..."
|
||||
git fetch --tags
|
||||
|
||||
# Build new image in background (doesn't stop running container)
|
||||
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
||||
docker compose build
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Export version if available
|
||||
if [ -f /app/version.txt ]; then
|
||||
export APP_VERSION=$(cat /app/version.txt)
|
||||
echo "App version: $APP_VERSION"
|
||||
fi
|
||||
|
||||
echo "Starting deployment..."
|
||||
|
||||
# Run migrations
|
||||
echo "Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Run release year migration (only if not already done)
|
||||
# Run release year migration (idempotent, skips if all done)
|
||||
echo "Running release year migration check..."
|
||||
node scripts/migrate-release-years.mjs
|
||||
|
||||
|
||||
# Start the application
|
||||
echo "Starting application..."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { parseBuffer } from 'music-metadata';
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -11,6 +11,16 @@ const __dirname = path.dirname(__filename);
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function migrate() {
|
||||
// Check if migration already ran
|
||||
const flagPath = path.join(process.cwd(), '.covers-migrated');
|
||||
try {
|
||||
await access(flagPath);
|
||||
console.log('✅ Cover migration already completed (flag file exists). Skipping...');
|
||||
return;
|
||||
} catch {
|
||||
// Flag file doesn't exist, proceed with migration
|
||||
}
|
||||
|
||||
console.log('Starting cover art migration...');
|
||||
|
||||
try {
|
||||
@@ -23,10 +33,18 @@ async function migrate() {
|
||||
|
||||
console.log(`Found ${songs.length} songs without cover image.`);
|
||||
|
||||
if (songs.length === 0) {
|
||||
console.log('✅ All songs already have cover images!');
|
||||
await writeFile(flagPath, new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let successful = 0;
|
||||
|
||||
for (const song of songs) {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||
console.log(`Processing ${song.title} (${song.filename})...`);
|
||||
|
||||
const buffer = await readFile(filePath);
|
||||
const metadata = await parseBuffer(buffer);
|
||||
@@ -47,14 +65,20 @@ async function migrate() {
|
||||
data: { coverImage: coverFilename }
|
||||
});
|
||||
|
||||
console.log(`✅ Extracted cover for ${song.title}`);
|
||||
successful++;
|
||||
}
|
||||
processed++;
|
||||
} catch (e) {
|
||||
console.error(`❌ Failed to process ${song.title}:`, e.message);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration completed.');
|
||||
console.log(`✅ Cover migration completed: ${successful}/${processed} songs processed successfully.`);
|
||||
|
||||
// Create flag file to prevent re-running
|
||||
await writeFile(flagPath, new Date().toISOString());
|
||||
console.log(`🏁 Created flag file: ${flagPath}`);
|
||||
} catch (e) {
|
||||
console.error('Migration failed:', e);
|
||||
} finally {
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// --- MusicBrainz Logic (Embedded to avoid TS import issues in Docker) ---
|
||||
|
||||
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
|
||||
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
|
||||
const RATE_LIMIT_DELAY = 250; // 250ms between requests (very conservative)
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url, maxRetries = 10) {
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 503) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000;
|
||||
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
||||
await sleep(waitTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxRetries - 1) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000;
|
||||
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
||||
await sleep(waitTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
async function getReleaseYear(artist, title) {
|
||||
const search = async (query, type) => {
|
||||
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`;
|
||||
await sleep(RATE_LIMIT_DELAY);
|
||||
const response = await fetchWithRetry(url);
|
||||
if (!response.ok) throw new Error(`API Error ${response.status}: ${response.statusText}`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Strict Search
|
||||
let data = await search(`artist:"${artist}" AND recording:"${title}"`, 'strict');
|
||||
|
||||
// 2. Fallback: Fuzzy Search if no recordings found
|
||||
if (!data.recordings || data.recordings.length === 0) {
|
||||
// Remove special chars and quotes for fuzzy search
|
||||
const cleanArtist = artist.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const cleanTitle = title.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Only try fuzzy if the cleaned strings are valid
|
||||
if (cleanArtist && cleanTitle) {
|
||||
console.log(` Trying fuzzy search for: ${cleanTitle} by ${cleanArtist}`);
|
||||
data = await search(`artist:${cleanArtist} AND recording:${cleanTitle}`, 'fuzzy');
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.recordings || data.recordings.length === 0) {
|
||||
console.log(` ❌ No recordings found for "${title}" by "${artist}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let earliestYear = null;
|
||||
|
||||
for (const recording of data.recordings) {
|
||||
// Check releases linked to recording
|
||||
if (recording.releases && recording.releases.length > 0) {
|
||||
for (const release of recording.releases) {
|
||||
if (release.date) {
|
||||
const year = parseInt(release.date.split('-')[0]);
|
||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
||||
earliestYear = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check first-release-date on recording itself
|
||||
if (recording['first-release-date']) {
|
||||
const year = parseInt(recording['first-release-date'].split('-')[0]);
|
||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
||||
earliestYear = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (earliestYear) {
|
||||
// console.log(` ✅ Found year: ${earliestYear}`);
|
||||
} else {
|
||||
console.log(` ⚠️ Recordings found but NO YEAR for "${title}" by "${artist}"`);
|
||||
}
|
||||
|
||||
return earliestYear;
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error fetching release year for "${title}" by "${artist}":`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Migration Logic ---
|
||||
|
||||
async function migrate() {
|
||||
console.log('🎵 Starting release year migration...');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
try {
|
||||
// Find songs without release year
|
||||
const songs = await prisma.song.findMany({
|
||||
where: {
|
||||
releaseYear: null
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📊 Found ${songs.length} songs without release year.\n`);
|
||||
|
||||
if (songs.length === 0) {
|
||||
console.log('✅ All songs already have release years!');
|
||||
await createFlagFile();
|
||||
return;
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let successful = 0;
|
||||
let failed = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
for (const song of songs) {
|
||||
processed++;
|
||||
// const progress = `[${processed}/${songs.length}]`;
|
||||
|
||||
try {
|
||||
// console.log(`${progress} Processing: "${song.title}" by "${song.artist}"`);
|
||||
|
||||
const releaseYear = await getReleaseYear(song.artist, song.title);
|
||||
|
||||
if (releaseYear) {
|
||||
await prisma.song.update({
|
||||
where: { id: song.id },
|
||||
data: { releaseYear }
|
||||
});
|
||||
successful++;
|
||||
// console.log(` ✅ Updated with year: ${releaseYear}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(` ⚠️ No release year found for "${song.title}" by "${song.artist}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
failed++;
|
||||
console.error(` ❌ Error processing song:`, error instanceof Error ? error.message : error);
|
||||
}
|
||||
|
||||
// Progress update every 10 songs (less verbose)
|
||||
if (processed % 10 === 0 || processed === songs.length) {
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||
const rate = processed / (elapsed || 1);
|
||||
const remaining = songs.length - processed;
|
||||
const eta = Math.round(remaining / rate);
|
||||
process.stdout.write(`\r📈 Progress: ${processed}/${songs.length} | Success: ${successful} | Failed: ${failed} | ETA: ${eta}s`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = Math.round((Date.now() - startTime) / 1000);
|
||||
console.log('\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('✅ Migration completed!');
|
||||
console.log(`📊 Total: ${processed} | Success: ${successful} | Failed: ${failed}`);
|
||||
console.log(`⏱️ Time: ${totalTime}s (${(processed / (totalTime || 1)).toFixed(2)} songs/s)`);
|
||||
|
||||
await createFlagFile();
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function createFlagFile() {
|
||||
const flagPath = path.join(process.cwd(), '.release-years-migrated');
|
||||
await writeFile(flagPath, new Date().toISOString());
|
||||
console.log(`\n🏁 Created flag file: ${flagPath}`);
|
||||
}
|
||||
|
||||
migrate();
|
||||
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