diff --git a/app/admin/page.tsx b/app/admin/page.tsx index a68cdf5..83a9836 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; interface Special { @@ -47,6 +47,7 @@ interface Song { specials: Special[]; averageRating: number; ratingCount: number; + excludeFromGlobal: boolean; } type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear'; @@ -95,10 +96,12 @@ export default function AdminPage() { const [editReleaseYear, setEditReleaseYear] = useState(''); const [editGenreIds, setEditGenreIds] = useState([]); const [editSpecialIds, setEditSpecialIds] = useState([]); + const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false); // Post-upload state const [uploadedSong, setUploadedSong] = useState(null); const [uploadGenreIds, setUploadGenreIds] = useState([]); + const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false); // AI Categorization state const [isCategorizing, setIsCategorizing] = useState(false); @@ -123,6 +126,7 @@ export default function AdminPage() { const [dailyPuzzles, setDailyPuzzles] = useState([]); const [playingPuzzleId, setPlayingPuzzleId] = useState(null); const [showDailyPuzzles, setShowDailyPuzzles] = useState(false); + const fileInputRef = useRef(null); // Check for existing auth on mount useEffect(() => { @@ -478,8 +482,11 @@ export default function AdminPage() { setUploadProgress({ current: i + 1, total: files.length }); try { + console.log(`Uploading file ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`); + const formData = new FormData(); formData.append('file', file); + formData.append('excludeFromGlobal', String(uploadExcludeFromGlobal)); const res = await fetch('/api/songs', { method: 'POST', @@ -487,8 +494,11 @@ export default function AdminPage() { body: formData, }); + console.log(`Response status for ${file.name}: ${res.status}`); + if (res.ok) { const data = await res.json(); + console.log(`Upload successful for ${file.name}:`, data); results.push({ filename: file.name, success: true, @@ -498,6 +508,7 @@ export default function AdminPage() { } else if (res.status === 409) { // Duplicate detected const data = await res.json(); + console.log(`Duplicate detected for ${file.name}:`, data); results.push({ filename: file.name, success: false, @@ -506,17 +517,20 @@ export default function AdminPage() { error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"` }); } else { + const errorText = await res.text(); + console.error(`Upload failed for ${file.name} (${res.status}):`, errorText); results.push({ filename: file.name, success: false, - error: 'Upload failed' + error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}` }); } } catch (error) { + console.error(`Network error for ${file.name}:`, error); results.push({ filename: file.name, success: false, - error: 'Network error' + error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}` }); } } @@ -553,32 +567,81 @@ export default function AdminPage() { } }; + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); - setIsDragging(true); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + if (!isDragging) setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); + e.stopPropagation(); + + // Prevent flickering when dragging over children + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); + e.stopPropagation(); setIsDragging(false); - const droppedFiles = Array.from(e.dataTransfer.files).filter( - file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3') - ); + const droppedFiles = Array.from(e.dataTransfer.files); - if (droppedFiles.length > 0) { - setFiles(droppedFiles); + // Validate file types + 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) => { 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 +678,7 @@ export default function AdminPage() { setEditReleaseYear(song.releaseYear || ''); setEditGenreIds(song.genres.map(g => g.id)); setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []); + setEditExcludeFromGlobal(song.excludeFromGlobal || false); }; const cancelEditing = () => { @@ -624,6 +688,7 @@ export default function AdminPage() { setEditReleaseYear(''); setEditGenreIds([]); setEditSpecialIds([]); + setEditExcludeFromGlobal(false); }; const saveEditing = async (id: number) => { @@ -636,7 +701,8 @@ export default function AdminPage() { artist: editArtist, releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear), genreIds: editGenreIds, - specialIds: editSpecialIds + specialIds: editSpecialIds, + excludeFromGlobal: editExcludeFromGlobal }), }); @@ -739,6 +805,8 @@ export default function AdminPage() { } else if (selectedGenreFilter === 'daily') { const today = new Date().toISOString().split('T')[0]; matchesFilter = song.puzzles?.some(p => p.date === today) || false; + } else if (selectedGenreFilter === 'no-global') { + matchesFilter = song.excludeFromGlobal === true; } } @@ -1052,6 +1120,7 @@ export default function AdminPage() {
{/* Drag & Drop Zone */}
document.getElementById('file-input')?.click()} + onClick={() => fileInputRef.current?.click()} >
📁

@@ -1075,7 +1144,7 @@ export default function AdminPage() { or click to browse

)} +
+ +

+ If checked, these songs will only appear in Genre or Special puzzles. +

+
+
+
+ +
{new Date(song.createdAt).toLocaleDateString('de-DE')} @@ -1416,6 +1511,24 @@ export default function AdminPage() {
{song.title}
{song.artist}
+ {song.excludeFromGlobal && ( +
+ + 🚫 No Global + +
+ )} + {/* Daily Puzzle Badges */}
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => { diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts index 5a832fb..2bd4900 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -8,6 +8,10 @@ import { requireAdminAuth } from '@/lib/auth'; const prisma = new PrismaClient(); +// Configure route to handle large file uploads +export const runtime = 'nodejs'; +export const maxDuration = 60; // 60 seconds timeout for uploads + export async function GET() { const songs = await prisma.song.findMany({ orderBy: { createdAt: 'desc' }, @@ -37,23 +41,35 @@ export async function GET() { specials: song.specials.map(ss => ss.special), averageRating: song.averageRating, ratingCount: song.ratingCount, + excludeFromGlobal: song.excludeFromGlobal, })); return NextResponse.json(songsWithActivations); } export async function POST(request: Request) { + console.log('[UPLOAD] Starting song upload request'); + // Check authentication const authError = await requireAdminAuth(request as any); - if (authError) return authError; + if (authError) { + console.log('[UPLOAD] Authentication failed'); + return authError; + } try { + console.log('[UPLOAD] Parsing form data...'); const formData = await request.formData(); const file = formData.get('file') as File; let title = ''; let artist = ''; + const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true'; + + console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type); + console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal); if (!file) { + console.error('[UPLOAD] No file provided'); return NextResponse.json({ error: 'No file provided' }, { status: 400 }); } @@ -81,6 +97,7 @@ export async function POST(request: Request) { } const buffer = Buffer.from(await file.arrayBuffer()); + console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes'); // Validate and extract metadata from file let metadata; @@ -208,10 +225,9 @@ export async function POST(request: Request) { console.error('Failed to extract cover image:', e); } - // Fetch release year (iTunes first, then MusicBrainz) + // Fetch release year from iTunes let releaseYear = null; try { - // Try iTunes first const { getReleaseYearFromItunes } = await import('@/lib/itunes'); releaseYear = await getReleaseYearFromItunes(artist, title); @@ -229,6 +245,7 @@ export async function POST(request: Request) { filename, coverImage, releaseYear, + excludeFromGlobal, }, include: { genres: true, specials: true } }); @@ -249,7 +266,7 @@ export async function PUT(request: Request) { if (authError) return authError; try { - const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json(); + const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json(); if (!id || !title || !artist) { return NextResponse.json({ error: 'Missing fields' }, { status: 400 }); @@ -262,6 +279,10 @@ export async function PUT(request: Request) { data.releaseYear = releaseYear; } + if (excludeFromGlobal !== undefined) { + data.excludeFromGlobal = excludeFromGlobal; + } + if (genreIds) { data.genres = { set: genreIds.map((gId: number) => ({ id: gId })) diff --git a/lib/dailyPuzzle.ts b/lib/dailyPuzzle.ts index f7b8a41..8e46d21 100644 --- a/lib/dailyPuzzle.ts +++ b/lib/dailyPuzzle.ts @@ -33,7 +33,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) { // Get songs available for this genre const whereClause = genreId ? { genres: { some: { id: genreId } } } - : {}; // Global puzzle picks from ALL songs + : { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded) const allSongs = await prisma.song.findMany({ where: whereClause, diff --git a/next.config.ts b/next.config.ts index 9f2fc56..9b78294 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,7 @@ const nextConfig: NextConfig = { serverActions: { bodySizeLimit: '50mb', }, + middlewareClientMaxBodySize: '50mb', }, env: { TZ: process.env.TZ || 'Europe/Berlin', diff --git a/prisma/dev.db.bak b/prisma/dev.db.bak new file mode 100644 index 0000000..c124225 Binary files /dev/null and b/prisma/dev.db.bak differ diff --git a/prisma/migrations/20251124182259_add_exclude_from_global/migration.sql b/prisma/migrations/20251124182259_add_exclude_from_global/migration.sql new file mode 100644 index 0000000..036677c --- /dev/null +++ b/prisma/migrations/20251124182259_add_exclude_from_global/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f441e2..6b398c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model Song { specials SpecialSong[] averageRating Float @default(0) ratingCount Int @default(0) + excludeFromGlobal Boolean @default(false) } model Genre {