Compare commits
16 Commits
326023a705
...
v0.1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7aec80f39 | ||
|
|
0e313db2e3 | ||
|
|
3e647cd44b | ||
|
|
54af256e91 | ||
|
|
ce413cf6bc | ||
|
|
5102ca86cb | ||
|
|
eb3d2c86d7 | ||
|
|
883875b82a | ||
|
|
4c13817e77 | ||
|
|
35fe5f2d44 | ||
|
|
70501d626b | ||
|
|
41ce6c12ce | ||
|
|
a744393335 | ||
|
|
0ee3a48770 | ||
|
|
187774bce7 | ||
|
|
67cf85dc22 |
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
|
# Rebuild the source code only when needed
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Accept version as build argument (optional)
|
||||||
|
ARG APP_VERSION=""
|
||||||
|
|
||||||
|
# Install git to extract version information
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Extract version: use build arg if provided, otherwise get from git
|
||||||
|
RUN if [ -n "$APP_VERSION" ]; then \
|
||||||
|
echo "$APP_VERSION" > /tmp/version.txt; \
|
||||||
|
else \
|
||||||
|
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
|
||||||
|
fi && \
|
||||||
|
echo "Building version: $(cat /tmp/version.txt)"
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
@@ -53,6 +68,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
|||||||
# Create uploads directory and set permissions
|
# Create uploads directory and set permissions
|
||||||
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
|
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
|
||||||
|
|
||||||
|
# Copy version file from builder
|
||||||
|
COPY --from=builder /tmp/version.txt /app/version.txt
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
16
README.md
16
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).
|
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
|
||||||
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
|
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
|
||||||
- Bearbeitung von Metadaten.
|
- Bearbeitung von Metadaten.
|
||||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am).
|
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||||
- **Cover Art:**
|
- **Cover Art:**
|
||||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||||
- Automatische Migration bestehender Songs.
|
- Automatische Migration bestehender Songs.
|
||||||
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden.
|
- **Teilen-Funktion:**
|
||||||
|
- Ergebnisse können als Emoji-Grid geteilt werden.
|
||||||
|
- Stern-Symbol (⭐) bei korrekt beantworteter Bonusfrage.
|
||||||
|
- Automatische Anpassung für Genre- und Special-Rätsel.
|
||||||
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
||||||
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
||||||
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
||||||
- **Genre-Management:**
|
- **Genre-Management:**
|
||||||
- Erstellen und Verwalten von Musik-Genres.
|
- Erstellen und Verwalten von Musik-Genres.
|
||||||
|
- **Aktivierung/Deaktivierung:** Genres können aktiviert oder deaktiviert werden (deaktivierte Genres sind nicht auf der Startseite sichtbar und ihre Routen sind nicht erreichbar).
|
||||||
- Manuelle Zuweisung von Genres zu Songs.
|
- Manuelle Zuweisung von Genres zu Songs.
|
||||||
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
||||||
- Genre-spezifische tägliche Rätsel.
|
- Genre-spezifische tägliche Rätsel.
|
||||||
@@ -37,7 +41,15 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Live-Vorschau beim Hovern über die Waveform.
|
- Live-Vorschau beim Hovern über die Waveform.
|
||||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
- Manuelle Speicherung mit visueller Bestätigung.
|
- Manuelle Speicherung mit visueller Bestätigung.
|
||||||
|
- **News & Announcements:**
|
||||||
|
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||||
|
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
||||||
|
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
||||||
|
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||||
|
- **Special-Verknüpfung:** Direkte Links zu Specials in News-Beiträgen.
|
||||||
|
- Verwaltung über das Admin-Dashboard.
|
||||||
|
|
||||||
## Spielregeln & Punktesystem
|
## Spielregeln & Punktesystem
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Game from '@/components/Game';
|
|||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -14,8 +15,21 @@ interface PageProps {
|
|||||||
export default async function GenrePage({ params }: PageProps) {
|
export default async function GenrePage({ params }: PageProps) {
|
||||||
const { genre } = await params;
|
const { genre } = await params;
|
||||||
const decodedGenre = decodeURIComponent(genre);
|
const decodedGenre = decodeURIComponent(genre);
|
||||||
|
|
||||||
|
// Check if genre exists and is active
|
||||||
|
const currentGenre = await prisma.genre.findUnique({
|
||||||
|
where: { name: decodedGenre }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentGenre || !currentGenre.active) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
||||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
|
||||||
interface Special {
|
interface Special {
|
||||||
@@ -21,6 +21,7 @@ interface Genre {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
active: boolean;
|
||||||
_count?: {
|
_count?: {
|
||||||
songs: number;
|
songs: number;
|
||||||
};
|
};
|
||||||
@@ -47,9 +48,24 @@ interface Song {
|
|||||||
specials: Special[];
|
specials: Special[];
|
||||||
averageRating: number;
|
averageRating: number;
|
||||||
ratingCount: number;
|
ratingCount: number;
|
||||||
|
excludeFromGlobal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
|
interface News {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
author: string | null;
|
||||||
|
publishedAt: string;
|
||||||
|
featured: boolean;
|
||||||
|
specialId: number | null;
|
||||||
|
special: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
@@ -65,9 +81,11 @@ export default function AdminPage() {
|
|||||||
const [genres, setGenres] = useState<Genre[]>([]);
|
const [genres, setGenres] = useState<Genre[]>([]);
|
||||||
const [newGenreName, setNewGenreName] = useState('');
|
const [newGenreName, setNewGenreName] = useState('');
|
||||||
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
|
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
|
||||||
|
const [newGenreActive, setNewGenreActive] = useState(true);
|
||||||
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
|
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
|
||||||
const [editGenreName, setEditGenreName] = useState('');
|
const [editGenreName, setEditGenreName] = useState('');
|
||||||
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
|
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
|
||||||
|
const [editGenreActive, setEditGenreActive] = useState(true);
|
||||||
|
|
||||||
// Specials state
|
// Specials state
|
||||||
const [specials, setSpecials] = useState<Special[]>([]);
|
const [specials, setSpecials] = useState<Special[]>([]);
|
||||||
@@ -88,6 +106,20 @@ export default function AdminPage() {
|
|||||||
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
|
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
|
||||||
const [editSpecialCurator, setEditSpecialCurator] = useState('');
|
const [editSpecialCurator, setEditSpecialCurator] = useState('');
|
||||||
|
|
||||||
|
// News state
|
||||||
|
const [news, setNews] = useState<News[]>([]);
|
||||||
|
const [newNewsTitle, setNewNewsTitle] = useState('');
|
||||||
|
const [newNewsContent, setNewNewsContent] = useState('');
|
||||||
|
const [newNewsAuthor, setNewNewsAuthor] = useState('');
|
||||||
|
const [newNewsFeatured, setNewNewsFeatured] = useState(false);
|
||||||
|
const [newNewsSpecialId, setNewNewsSpecialId] = useState<number | null>(null);
|
||||||
|
const [editingNewsId, setEditingNewsId] = useState<number | null>(null);
|
||||||
|
const [editNewsTitle, setEditNewsTitle] = useState('');
|
||||||
|
const [editNewsContent, setEditNewsContent] = useState('');
|
||||||
|
const [editNewsAuthor, setEditNewsAuthor] = useState('');
|
||||||
|
const [editNewsFeatured, setEditNewsFeatured] = useState(false);
|
||||||
|
const [editNewsSpecialId, setEditNewsSpecialId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Edit state
|
// Edit state
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editTitle, setEditTitle] = useState('');
|
const [editTitle, setEditTitle] = useState('');
|
||||||
@@ -95,10 +127,15 @@ export default function AdminPage() {
|
|||||||
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
|
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
|
||||||
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
||||||
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
|
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
|
||||||
|
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
|
||||||
|
|
||||||
// Post-upload state
|
// Post-upload state
|
||||||
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
||||||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||||||
|
const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false);
|
||||||
|
|
||||||
|
// Batch upload genre selection
|
||||||
|
const [batchUploadGenreIds, setBatchUploadGenreIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// AI Categorization state
|
// AI Categorization state
|
||||||
const [isCategorizing, setIsCategorizing] = useState(false);
|
const [isCategorizing, setIsCategorizing] = useState(false);
|
||||||
@@ -123,6 +160,7 @@ export default function AdminPage() {
|
|||||||
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
|
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
|
||||||
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
|
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
|
||||||
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
|
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Check for existing auth on mount
|
// Check for existing auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,6 +170,8 @@ export default function AdminPage() {
|
|||||||
fetchSongs();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
fetchDailyPuzzles();
|
fetchDailyPuzzles();
|
||||||
|
fetchSpecials();
|
||||||
|
fetchNews();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -146,6 +186,8 @@ export default function AdminPage() {
|
|||||||
fetchSongs();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
fetchDailyPuzzles();
|
fetchDailyPuzzles();
|
||||||
|
fetchSpecials();
|
||||||
|
fetchNews();
|
||||||
} else {
|
} else {
|
||||||
alert('Wrong password');
|
alert('Wrong password');
|
||||||
}
|
}
|
||||||
@@ -196,11 +238,16 @@ export default function AdminPage() {
|
|||||||
const res = await fetch('/api/genres', {
|
const res = await fetch('/api/genres', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
|
body: JSON.stringify({
|
||||||
|
name: newGenreName,
|
||||||
|
subtitle: newGenreSubtitle,
|
||||||
|
active: newGenreActive
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setNewGenreName('');
|
setNewGenreName('');
|
||||||
setNewGenreSubtitle('');
|
setNewGenreSubtitle('');
|
||||||
|
setNewGenreActive(true);
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to create genre');
|
alert('Failed to create genre');
|
||||||
@@ -211,6 +258,7 @@ export default function AdminPage() {
|
|||||||
setEditingGenreId(genre.id);
|
setEditingGenreId(genre.id);
|
||||||
setEditGenreName(genre.name);
|
setEditGenreName(genre.name);
|
||||||
setEditGenreSubtitle(genre.subtitle || '');
|
setEditGenreSubtitle(genre.subtitle || '');
|
||||||
|
setEditGenreActive(genre.active !== undefined ? genre.active : true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEditedGenre = async () => {
|
const saveEditedGenre = async () => {
|
||||||
@@ -221,7 +269,8 @@ export default function AdminPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: editingGenreId,
|
id: editingGenreId,
|
||||||
name: editGenreName,
|
name: editGenreName,
|
||||||
subtitle: editGenreSubtitle
|
subtitle: editGenreSubtitle,
|
||||||
|
active: editGenreActive
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -377,6 +426,94 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// News functions
|
||||||
|
const fetchNews = async () => {
|
||||||
|
const res = await fetch('/api/news', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setNews(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNews = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newNewsTitle.trim() || !newNewsContent.trim()) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/news', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: newNewsTitle,
|
||||||
|
content: newNewsContent,
|
||||||
|
author: newNewsAuthor || null,
|
||||||
|
featured: newNewsFeatured,
|
||||||
|
specialId: newNewsSpecialId
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setNewNewsTitle('');
|
||||||
|
setNewNewsContent('');
|
||||||
|
setNewNewsAuthor('');
|
||||||
|
setNewNewsFeatured(false);
|
||||||
|
setNewNewsSpecialId(null);
|
||||||
|
fetchNews();
|
||||||
|
} else {
|
||||||
|
alert('Failed to create news');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditNews = (newsItem: News) => {
|
||||||
|
setEditingNewsId(newsItem.id);
|
||||||
|
setEditNewsTitle(newsItem.title);
|
||||||
|
setEditNewsContent(newsItem.content);
|
||||||
|
setEditNewsAuthor(newsItem.author || '');
|
||||||
|
setEditNewsFeatured(newsItem.featured);
|
||||||
|
setEditNewsSpecialId(newsItem.specialId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEditedNews = async () => {
|
||||||
|
if (editingNewsId === null) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/news', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: editingNewsId,
|
||||||
|
title: editNewsTitle,
|
||||||
|
content: editNewsContent,
|
||||||
|
author: editNewsAuthor || null,
|
||||||
|
featured: editNewsFeatured,
|
||||||
|
specialId: editNewsSpecialId
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setEditingNewsId(null);
|
||||||
|
fetchNews();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update news');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteNews = async (id: number) => {
|
||||||
|
if (!confirm('Delete this news item?')) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/news', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
fetchNews();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete news');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Load specials after auth
|
// Load specials after auth
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) fetchSpecials();
|
if (isAuthenticated) fetchSpecials();
|
||||||
@@ -478,8 +615,11 @@ export default function AdminPage() {
|
|||||||
setUploadProgress({ current: i + 1, total: files.length });
|
setUploadProgress({ current: i + 1, total: files.length });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`Uploading file ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
formData.append('excludeFromGlobal', String(uploadExcludeFromGlobal));
|
||||||
|
|
||||||
const res = await fetch('/api/songs', {
|
const res = await fetch('/api/songs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -487,8 +627,11 @@ export default function AdminPage() {
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`Response status for ${file.name}: ${res.status}`);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
console.log(`Upload successful for ${file.name}:`, data);
|
||||||
results.push({
|
results.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -498,6 +641,7 @@ export default function AdminPage() {
|
|||||||
} else if (res.status === 409) {
|
} else if (res.status === 409) {
|
||||||
// Duplicate detected
|
// Duplicate detected
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
console.log(`Duplicate detected for ${file.name}:`, data);
|
||||||
results.push({
|
results.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: false,
|
success: false,
|
||||||
@@ -506,17 +650,20 @@ export default function AdminPage() {
|
|||||||
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
|
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const errorText = await res.text();
|
||||||
|
console.error(`Upload failed for ${file.name} (${res.status}):`, errorText);
|
||||||
results.push({
|
results.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Upload failed'
|
error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`Network error for ${file.name}:`, error);
|
||||||
results.push({
|
results.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Network error'
|
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,6 +671,28 @@ export default function AdminPage() {
|
|||||||
setUploadResults(results);
|
setUploadResults(results);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
|
||||||
|
// Assign genres to successfully uploaded songs
|
||||||
|
if (batchUploadGenreIds.length > 0) {
|
||||||
|
const successfulUploads = results.filter(r => r.success && r.song);
|
||||||
|
for (const result of successfulUploads) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/songs', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: result.song.id,
|
||||||
|
title: result.song.title,
|
||||||
|
artist: result.song.artist,
|
||||||
|
genreIds: batchUploadGenreIds
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to assign genres to ${result.song.title}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchSongs();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
fetchSpecials(); // Update special counts
|
fetchSpecials(); // Update special counts
|
||||||
@@ -540,6 +709,13 @@ export default function AdminPage() {
|
|||||||
if (failedCount > 0) {
|
if (failedCount > 0) {
|
||||||
msg += `\n❌ ${failedCount} failed`;
|
msg += `\n❌ ${failedCount} failed`;
|
||||||
}
|
}
|
||||||
|
if (batchUploadGenreIds.length > 0) {
|
||||||
|
const selectedGenreNames = genres
|
||||||
|
.filter(g => batchUploadGenreIds.includes(g.id))
|
||||||
|
.map(g => g.name)
|
||||||
|
.join(', ');
|
||||||
|
msg += `\n🏷️ Assigned genres: ${selectedGenreNames}`;
|
||||||
|
}
|
||||||
msg += '\n\n🤖 Starting auto-categorization...';
|
msg += '\n\n🤖 Starting auto-categorization...';
|
||||||
setMessage(msg);
|
setMessage(msg);
|
||||||
// Small delay to let user see the message
|
// Small delay to let user see the message
|
||||||
@@ -553,32 +729,81 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
if (!isDragging) setIsDragging(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Prevent flickering when dragging over children
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files).filter(
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (droppedFiles.length > 0) {
|
// Validate file types
|
||||||
setFiles(droppedFiles);
|
const validFiles: File[] = [];
|
||||||
|
const invalidFiles: string[] = [];
|
||||||
|
|
||||||
|
droppedFiles.forEach(file => {
|
||||||
|
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
|
||||||
|
validFiles.push(file);
|
||||||
|
} else {
|
||||||
|
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
setFiles(validFiles);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (e.target.files) {
|
||||||
setFiles(Array.from(e.target.files));
|
const selectedFiles = Array.from(e.target.files);
|
||||||
|
|
||||||
|
// Validate file types
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
const invalidFiles: string[] = [];
|
||||||
|
|
||||||
|
selectedFiles.forEach(file => {
|
||||||
|
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
|
||||||
|
validFiles.push(file);
|
||||||
|
} else {
|
||||||
|
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
setFiles(validFiles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -615,6 +840,7 @@ export default function AdminPage() {
|
|||||||
setEditReleaseYear(song.releaseYear || '');
|
setEditReleaseYear(song.releaseYear || '');
|
||||||
setEditGenreIds(song.genres.map(g => g.id));
|
setEditGenreIds(song.genres.map(g => g.id));
|
||||||
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
|
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
|
||||||
|
setEditExcludeFromGlobal(song.excludeFromGlobal || false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
@@ -624,6 +850,7 @@ export default function AdminPage() {
|
|||||||
setEditReleaseYear('');
|
setEditReleaseYear('');
|
||||||
setEditGenreIds([]);
|
setEditGenreIds([]);
|
||||||
setEditSpecialIds([]);
|
setEditSpecialIds([]);
|
||||||
|
setEditExcludeFromGlobal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEditing = async (id: number) => {
|
const saveEditing = async (id: number) => {
|
||||||
@@ -636,7 +863,8 @@ export default function AdminPage() {
|
|||||||
artist: editArtist,
|
artist: editArtist,
|
||||||
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
|
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
|
||||||
genreIds: editGenreIds,
|
genreIds: editGenreIds,
|
||||||
specialIds: editSpecialIds
|
specialIds: editSpecialIds,
|
||||||
|
excludeFromGlobal: editExcludeFromGlobal
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -739,6 +967,8 @@ export default function AdminPage() {
|
|||||||
} else if (selectedGenreFilter === 'daily') {
|
} else if (selectedGenreFilter === 'daily') {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
|
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
|
||||||
|
} else if (selectedGenreFilter === 'no-global') {
|
||||||
|
matchesFilter = song.excludeFromGlobal === true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,7 +976,7 @@ export default function AdminPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||||||
// Handle numeric sorting for ID and Release Year
|
// Handle numeric sorting for ID, Release Year, Activations, and Rating
|
||||||
if (sortField === 'id') {
|
if (sortField === 'id') {
|
||||||
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
||||||
}
|
}
|
||||||
@@ -755,6 +985,12 @@ export default function AdminPage() {
|
|||||||
const yearB = b.releaseYear || 0;
|
const yearB = b.releaseYear || 0;
|
||||||
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
|
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
|
||||||
}
|
}
|
||||||
|
if (sortField === 'activations') {
|
||||||
|
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
|
||||||
|
}
|
||||||
|
if (sortField === 'averageRating') {
|
||||||
|
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
|
||||||
|
}
|
||||||
|
|
||||||
// String sorting for other fields
|
// String sorting for other fields
|
||||||
const valA = String(a[sortField]).toLowerCase();
|
const valA = String(a[sortField]).toLowerCase();
|
||||||
@@ -910,7 +1146,7 @@ export default function AdminPage() {
|
|||||||
{/* Genre Management */}
|
{/* Genre Management */}
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newGenreName}
|
value={newGenreName}
|
||||||
@@ -927,12 +1163,21 @@ export default function AdminPage() {
|
|||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ maxWidth: '300px' }}
|
style={{ maxWidth: '300px' }}
|
||||||
/>
|
/>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newGenreActive}
|
||||||
|
onChange={e => setNewGenreActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
<button onClick={createGenre} className="btn-primary">Add Genre</button>
|
<button onClick={createGenre} className="btn-primary">Add Genre</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
{genres.map(genre => (
|
{genres.map(genre => (
|
||||||
<div key={genre.id} style={{
|
<div key={genre.id} style={{
|
||||||
background: '#f3f4f6',
|
background: genre.active ? '#f3f4f6' : '#fee2e2',
|
||||||
|
opacity: genre.active ? 1 : 0.8,
|
||||||
padding: '0.25rem 0.75rem',
|
padding: '0.25rem 0.75rem',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -959,6 +1204,16 @@ export default function AdminPage() {
|
|||||||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
||||||
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
|
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', paddingBottom: '0.5rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editGenreActive}
|
||||||
|
onChange={e => setEditGenreActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button onClick={saveEditedGenre} className="btn-primary">Save</button>
|
<button onClick={saveEditedGenre} className="btn-primary">Save</button>
|
||||||
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
|
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1047,11 +1302,169 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* News Management */}
|
||||||
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage News & Announcements</h2>
|
||||||
|
<form onSubmit={handleCreateNews} style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newNewsTitle}
|
||||||
|
onChange={e => setNewNewsTitle(e.target.value)}
|
||||||
|
placeholder="News Title"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={newNewsContent}
|
||||||
|
onChange={e => setNewNewsContent(e.target.value)}
|
||||||
|
placeholder="Content (Markdown supported)"
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newNewsAuthor}
|
||||||
|
onChange={e => setNewNewsAuthor(e.target.value)}
|
||||||
|
placeholder="Author (optional)"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newNewsSpecialId || ''}
|
||||||
|
onChange={e => setNewNewsSpecialId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
>
|
||||||
|
<option value="">No Special Link</option>
|
||||||
|
{specials.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newNewsFeatured}
|
||||||
|
onChange={e => setNewNewsFeatured(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Featured
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn-primary">Add News</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{news.map(newsItem => (
|
||||||
|
<div key={newsItem.id} style={{
|
||||||
|
background: newsItem.featured ? '#fef3c7' : '#f3f4f6',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: newsItem.featured ? '2px solid #f59e0b' : '1px solid #e5e7eb'
|
||||||
|
}}>
|
||||||
|
{editingNewsId === newsItem.id ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editNewsTitle}
|
||||||
|
onChange={e => setEditNewsTitle(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={editNewsContent}
|
||||||
|
onChange={e => setEditNewsContent(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editNewsAuthor}
|
||||||
|
onChange={e => setEditNewsAuthor(e.target.value)}
|
||||||
|
placeholder="Author"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={editNewsSpecialId || ''}
|
||||||
|
onChange={e => setEditNewsSpecialId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
>
|
||||||
|
<option value="">No Special Link</option>
|
||||||
|
{specials.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editNewsFeatured}
|
||||||
|
onChange={e => setEditNewsFeatured(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Featured
|
||||||
|
</label>
|
||||||
|
<button onClick={saveEditedNews} className="btn-primary">Save</button>
|
||||||
|
<button onClick={() => setEditingNewsId(null)} className="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||||
|
{newsItem.featured && (
|
||||||
|
<span style={{
|
||||||
|
background: '#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
padding: '0.125rem 0.375rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
⭐ FEATURED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: '600' }}>{newsItem.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#666', marginBottom: '0.5rem' }}>
|
||||||
|
{new Date(newsItem.publishedAt).toLocaleDateString('de-DE')}
|
||||||
|
{newsItem.author && ` • by ${newsItem.author}`}
|
||||||
|
{newsItem.special && ` • ★ ${newsItem.special.name}`}
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{newsItem.content.length > 150
|
||||||
|
? newsItem.content.substring(0, 150) + '...'
|
||||||
|
: newsItem.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', marginLeft: '1rem' }}>
|
||||||
|
<button onClick={() => startEditNews(newsItem)} className="btn-secondary" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Edit</button>
|
||||||
|
<button onClick={() => handleDeleteNews(newsItem.id)} className="btn-danger" style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{news.length === 0 && (
|
||||||
|
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', padding: '1rem' }}>
|
||||||
|
No news items yet. Create one above!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
|
||||||
<form onSubmit={handleBatchUpload}>
|
<form onSubmit={handleBatchUpload}>
|
||||||
{/* Drag & Drop Zone */}
|
{/* Drag & Drop Zone */}
|
||||||
<div
|
<div
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -1065,7 +1478,7 @@ export default function AdminPage() {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
onClick={() => document.getElementById('file-input')?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
|
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
||||||
@@ -1075,7 +1488,7 @@ export default function AdminPage() {
|
|||||||
or click to browse
|
or click to browse
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
id="file-input"
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/mpeg"
|
accept="audio/mpeg"
|
||||||
multiple
|
multiple
|
||||||
@@ -1115,6 +1528,63 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
|
||||||
|
Assign Genres (optional)
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
{genres.map(genre => (
|
||||||
|
<label
|
||||||
|
key={genre.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: batchUploadGenreIds.includes(genre.id) ? '#dbeafe' : '#f3f4f6',
|
||||||
|
border: batchUploadGenreIds.includes(genre.id) ? '2px solid #3b82f6' : '2px solid transparent',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={batchUploadGenreIds.includes(genre.id)}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
|
||||||
|
} else {
|
||||||
|
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
{genre.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
|
||||||
|
Selected genres will be assigned to all uploaded songs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={uploadExcludeFromGlobal}
|
||||||
|
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
|
||||||
|
style={{ width: '1.25rem', height: '1.25rem' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
|
||||||
|
If checked, these songs will only appear in Genre or Special puzzles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
@@ -1235,6 +1705,7 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
<option value="">All Content</option>
|
<option value="">All Content</option>
|
||||||
<option value="daily">📅 Song of the Day</option>
|
<option value="daily">📅 Song of the Day</option>
|
||||||
|
<option value="no-global">🚫 No Global</option>
|
||||||
<optgroup label="Genres">
|
<optgroup label="Genres">
|
||||||
<option value="genre:-1">No Genre</option>
|
<option value="genre:-1">No Genre</option>
|
||||||
{genres.map(genre => (
|
{genres.map(genre => (
|
||||||
@@ -1300,8 +1771,18 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th style={{ padding: '0.75rem' }}>Activations</th>
|
<th
|
||||||
<th style={{ padding: '0.75rem' }}>Rating</th>
|
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
onClick={() => handleSort('activations')}
|
||||||
|
>
|
||||||
|
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
onClick={() => handleSort('averageRating')}
|
||||||
|
>
|
||||||
|
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
|
</th>
|
||||||
<th style={{ padding: '0.75rem' }}>Actions</th>
|
<th style={{ padding: '0.75rem' }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1377,6 +1858,16 @@ export default function AdminPage() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editExcludeFromGlobal}
|
||||||
|
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Exclude from Global
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||||||
@@ -1416,6 +1907,24 @@ export default function AdminPage() {
|
|||||||
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
|
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
|
||||||
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
|
||||||
|
|
||||||
|
{song.excludeFromGlobal && (
|
||||||
|
<div style={{ marginTop: '0.25rem' }}>
|
||||||
|
<span style={{
|
||||||
|
background: '#fee2e2',
|
||||||
|
color: '#991b1b',
|
||||||
|
padding: '0.1rem 0.4rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem'
|
||||||
|
}}>
|
||||||
|
🚫 No Global
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Daily Puzzle Badges */}
|
{/* Daily Puzzle Badges */}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
||||||
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
|
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
|
||||||
@@ -1605,73 +2114,7 @@ export default function AdminPage() {
|
|||||||
☢️ Rebuild Database
|
☢️ Rebuild Database
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ marginTop: '1rem', borderTop: '1px solid #eee', paddingTop: '1rem' }}>
|
|
||||||
<p style={{ marginBottom: '1rem', color: '#666' }}>
|
|
||||||
Update release years for all songs using the iTunes API. This will overwrite existing years.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (window.confirm('This will scan all songs and overwrite their release years using data from iTunes. This process may take a while.\n\nContinue?')) {
|
|
||||||
try {
|
|
||||||
let offset = 0;
|
|
||||||
let hasMore = true;
|
|
||||||
let totalUpdated = 0;
|
|
||||||
let totalSkipped = 0;
|
|
||||||
let totalFailed = 0;
|
|
||||||
let totalProcessed = 0;
|
|
||||||
let totalSongs = 0;
|
|
||||||
|
|
||||||
setMessage('Initializing release year refresh...');
|
|
||||||
|
|
||||||
while (hasMore) {
|
|
||||||
const res = await fetch('/api/admin/refresh-years', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ offset, limit: 10 }) // Process 10 at a time
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Batch request failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
totalUpdated += data.updated;
|
|
||||||
totalSkipped += data.skipped;
|
|
||||||
totalFailed += data.failed;
|
|
||||||
totalProcessed += data.processed;
|
|
||||||
totalSongs = data.total;
|
|
||||||
hasMore = data.hasMore;
|
|
||||||
offset = data.nextOffset;
|
|
||||||
|
|
||||||
setMessage(`Processing... ${totalProcessed} / ${totalSongs} songs.\nUpdated: ${totalUpdated} | Skipped: ${totalSkipped} | Failed: ${totalFailed}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalMsg = `✅ Completed!\nTotal Processed: ${totalProcessed}\nUpdated: ${totalUpdated}\nSkipped: ${totalSkipped}\nFailed: ${totalFailed}`;
|
|
||||||
alert(finalMsg);
|
|
||||||
setMessage(finalMsg);
|
|
||||||
fetchSongs(); // Refresh the table
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
alert('Process failed due to network error or timeout.');
|
|
||||||
setMessage('Refresh failed.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
background: '#f59e0b',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🔄 Refresh Release Years (iTunes)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
|
||||||
import { getReleaseYearFromItunes } from '@/lib/itunes';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// Helper to delay execution to avoid rate limits
|
|
||||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
// Check authentication
|
|
||||||
const authError = await requireAdminAuth(request as any);
|
|
||||||
if (authError) return authError;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { offset = 0, limit = 20 } = await request.json();
|
|
||||||
|
|
||||||
// Fetch batch of songs
|
|
||||||
const songs = await prisma.song.findMany({
|
|
||||||
select: { id: true, title: true, artist: true },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
skip: offset,
|
|
||||||
take: limit
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalSongs = await prisma.song.count();
|
|
||||||
|
|
||||||
console.log(`Processing batch: offset=${offset}, limit=${limit}, found=${songs.length}`);
|
|
||||||
|
|
||||||
let updatedCount = 0;
|
|
||||||
let failedCount = 0;
|
|
||||||
let skippedCount = 0;
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
for (const song of songs) {
|
|
||||||
try {
|
|
||||||
// Rate limiting: wait 2000ms between requests to be safe (iTunes can be strict)
|
|
||||||
await sleep(2000);
|
|
||||||
|
|
||||||
const year = await getReleaseYearFromItunes(song.artist, song.title);
|
|
||||||
|
|
||||||
if (year) {
|
|
||||||
await prisma.song.update({
|
|
||||||
where: { id: song.id },
|
|
||||||
data: { releaseYear: year }
|
|
||||||
});
|
|
||||||
updatedCount++;
|
|
||||||
results.push({ id: song.id, title: song.title, artist: song.artist, year, status: 'updated' });
|
|
||||||
} else {
|
|
||||||
skippedCount++;
|
|
||||||
results.push({ id: song.id, title: song.title, artist: song.artist, status: 'not_found' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to update year for ${song.title} - ${song.artist}:`, error);
|
|
||||||
failedCount++;
|
|
||||||
results.push({ id: song.id, title: song.title, artist: song.artist, status: 'error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
processed: songs.length,
|
|
||||||
total: totalSongs,
|
|
||||||
hasMore: offset + songs.length < totalSongs,
|
|
||||||
nextOffset: offset + songs.length,
|
|
||||||
updated: updatedCount,
|
|
||||||
failed: failedCount,
|
|
||||||
skipped: skippedCount,
|
|
||||||
results
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing release years:', error);
|
|
||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ export async function POST(request: Request) {
|
|||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, subtitle } = await request.json();
|
const { name, subtitle, active } = await request.json();
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||||
@@ -36,7 +36,8 @@ export async function POST(request: Request) {
|
|||||||
const genre = await prisma.genre.create({
|
const genre = await prisma.genre.create({
|
||||||
data: {
|
data: {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
subtitle: subtitle ? subtitle.trim() : null
|
subtitle: subtitle ? subtitle.trim() : null,
|
||||||
|
active: active !== undefined ? active : true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ export async function PUT(request: Request) {
|
|||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, name, subtitle } = await request.json();
|
const { id, name, subtitle, active } = await request.json();
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
@@ -86,7 +87,8 @@ export async function PUT(request: Request) {
|
|||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: {
|
||||||
...(name && { name: name.trim() }),
|
...(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 })
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import { requireAdminAuth } from '@/lib/auth';
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Configure route to handle large file uploads
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const songs = await prisma.song.findMany({
|
const songs = await prisma.song.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -37,23 +41,35 @@ export async function GET() {
|
|||||||
specials: song.specials.map(ss => ss.special),
|
specials: song.specials.map(ss => ss.special),
|
||||||
averageRating: song.averageRating,
|
averageRating: song.averageRating,
|
||||||
ratingCount: song.ratingCount,
|
ratingCount: song.ratingCount,
|
||||||
|
excludeFromGlobal: song.excludeFromGlobal,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(songsWithActivations);
|
return NextResponse.json(songsWithActivations);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
console.log('[UPLOAD] Starting song upload request');
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
const authError = await requireAdminAuth(request as any);
|
const authError = await requireAdminAuth(request as any);
|
||||||
if (authError) return authError;
|
if (authError) {
|
||||||
|
console.log('[UPLOAD] Authentication failed');
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[UPLOAD] Parsing form data...');
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
let title = '';
|
let title = '';
|
||||||
let artist = '';
|
let artist = '';
|
||||||
|
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||||
|
|
||||||
|
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||||
|
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
console.error('[UPLOAD] No file provided');
|
||||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +97,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
|
||||||
|
|
||||||
// Validate and extract metadata from file
|
// Validate and extract metadata from file
|
||||||
let metadata;
|
let metadata;
|
||||||
@@ -208,10 +225,9 @@ export async function POST(request: Request) {
|
|||||||
console.error('Failed to extract cover image:', e);
|
console.error('Failed to extract cover image:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch release year (iTunes first, then MusicBrainz)
|
// Fetch release year from iTunes
|
||||||
let releaseYear = null;
|
let releaseYear = null;
|
||||||
try {
|
try {
|
||||||
// Try iTunes first
|
|
||||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||||
releaseYear = await getReleaseYearFromItunes(artist, title);
|
releaseYear = await getReleaseYearFromItunes(artist, title);
|
||||||
|
|
||||||
@@ -229,6 +245,7 @@ export async function POST(request: Request) {
|
|||||||
filename,
|
filename,
|
||||||
coverImage,
|
coverImage,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
|
excludeFromGlobal,
|
||||||
},
|
},
|
||||||
include: { genres: true, specials: true }
|
include: { genres: true, specials: true }
|
||||||
});
|
});
|
||||||
@@ -249,7 +266,7 @@ export async function PUT(request: Request) {
|
|||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
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) {
|
if (!id || !title || !artist) {
|
||||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||||
@@ -262,6 +279,10 @@ export async function PUT(request: Request) {
|
|||||||
data.releaseYear = releaseYear;
|
data.releaseYear = releaseYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (excludeFromGlobal !== undefined) {
|
||||||
|
data.excludeFromGlobal = excludeFromGlobal;
|
||||||
|
}
|
||||||
|
|
||||||
if (genreIds) {
|
if (genreIds) {
|
||||||
data.genres = {
|
data.genres = {
|
||||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||||
|
|||||||
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export const viewport: Viewport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -36,15 +37,7 @@ export default function RootLayout({
|
|||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
<footer className="app-footer">
|
<AppFooter />
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Game from '@/components/Game';
|
import Game from '@/components/Game';
|
||||||
|
import NewsSection from '@/components/NewsSection';
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
@@ -9,7 +10,10 @@ const prisma = new PrismaClient();
|
|||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -90,6 +94,9 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NewsSection />
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
34
components/AppFooter.tsx
Normal file
34
components/AppFooter.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function AppFooter() {
|
||||||
|
const [version, setVersion] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/version')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setVersion(data.version))
|
||||||
|
.catch(() => setVersion(''));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p>
|
||||||
|
Vibe coded with ☕ and 🍺 by{' '}
|
||||||
|
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||||
|
@elpatron@digitalcourage.social
|
||||||
|
</a>
|
||||||
|
{' '}- for personal use among friends only!
|
||||||
|
{version && (
|
||||||
|
<>
|
||||||
|
{' '}·{' '}
|
||||||
|
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
|
||||||
|
{version}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -173,7 +173,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
|
|
||||||
const speaker = hasWon ? '🔉' : '🔇';
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
const genreText = genre ? `Genre: ${genre}\n` : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
|
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
||||||
|
|
||||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||||
if (genre) {
|
if (genre) {
|
||||||
@@ -184,7 +185,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 = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||||
|
|
||||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||||
|
|
||||||
@@ -332,7 +333,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
/>
|
/>
|
||||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||||
{dailyPuzzle.releaseYear && gameState.yearGuessed !== null && (
|
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||||
)}
|
)}
|
||||||
<audio controls style={{ width: '100%' }}>
|
<audio controls style={{ width: '100%' }}>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
// Get songs available for this genre
|
// Get songs available for this genre
|
||||||
const whereClause = genreId
|
const whereClause = genreId
|
||||||
? { genres: { some: { id: genreId } } }
|
? { genres: { some: { id: genreId } } }
|
||||||
: {}; // Global puzzle picks from ALL songs
|
: { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
|
||||||
|
|
||||||
const allSongs = await prisma.song.findMany({
|
const allSongs = await prisma.song.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
|
|||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
middlewareClientMaxBodySize: '50mb',
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
TZ: process.env.TZ || 'Europe/Berlin',
|
TZ: process.env.TZ || 'Europe/Berlin',
|
||||||
|
|||||||
1180
package-lock.json
generated
1180
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@
|
|||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"react-markdown": "^10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
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");
|
||||||
@@ -23,12 +23,14 @@ model Song {
|
|||||||
specials SpecialSong[]
|
specials SpecialSong[]
|
||||||
averageRating Float @default(0)
|
averageRating Float @default(0)
|
||||||
ratingCount Int @default(0)
|
ratingCount Int @default(0)
|
||||||
|
excludeFromGlobal Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Genre {
|
model Genre {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
subtitle String?
|
subtitle String?
|
||||||
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ model Special {
|
|||||||
curator String?
|
curator String?
|
||||||
songs SpecialSong[]
|
songs SpecialSong[]
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
|
news News[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SpecialSong {
|
model SpecialSong {
|
||||||
@@ -71,3 +74,17 @@ model DailyPuzzle {
|
|||||||
|
|
||||||
@@unique([date, genreId, specialId])
|
@@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..."
|
echo "📥 Pulling latest changes from git..."
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
|
# Fetch all tags
|
||||||
|
echo "🏷️ Fetching git tags..."
|
||||||
|
git fetch --tags
|
||||||
|
|
||||||
# Build new image in background (doesn't stop running container)
|
# Build new image in background (doesn't stop running container)
|
||||||
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Export version if available
|
||||||
|
if [ -f /app/version.txt ]; then
|
||||||
|
export APP_VERSION=$(cat /app/version.txt)
|
||||||
|
echo "App version: $APP_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting deployment..."
|
echo "Starting deployment..."
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
|
|||||||
Reference in New Issue
Block a user