diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 490c52a..0a0f09a 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -8,6 +8,7 @@ interface Song { artist: string; filename: string; createdAt: string; + activations: number; } type SortField = 'title' | 'artist'; @@ -17,8 +18,6 @@ export default function AdminPage() { const [password, setPassword] = useState(''); const [isAuthenticated, setIsAuthenticated] = useState(false); const [file, setFile] = useState(null); - const [title, setTitle] = useState(''); - const [artist, setArtist] = useState(''); const [message, setMessage] = useState(''); const [songs, setSongs] = useState([]); @@ -31,6 +30,11 @@ export default function AdminPage() { const [sortField, setSortField] = useState('artist'); const [sortDirection, setSortDirection] = useState('asc'); + // Search and pagination state + const [searchQuery, setSearchQuery] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + const handleLogin = async () => { const res = await fetch('/api/admin/login', { method: 'POST', @@ -58,8 +62,6 @@ export default function AdminPage() { const formData = new FormData(); formData.append('file', file); - if (title) formData.append('title', title); - if (artist) formData.append('artist', artist); setMessage('Uploading...'); const res = await fetch('/api/songs', { @@ -69,8 +71,6 @@ export default function AdminPage() { if (res.ok) { setMessage('Song uploaded successfully!'); - setTitle(''); - setArtist(''); setFile(null); fetchSongs(); } else { @@ -105,6 +105,24 @@ export default function AdminPage() { } }; + const handleDelete = async (id: number, title: string) => { + if (!confirm(`Are you sure you want to delete "${title}"? This will also delete the file.`)) { + return; + } + + const res = await fetch('/api/songs', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + + if (res.ok) { + fetchSongs(); + } else { + alert('Failed to delete song'); + } + }; + const handleSort = (field: SortField) => { if (sortField === field) { setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); @@ -114,7 +132,13 @@ export default function AdminPage() { } }; - const sortedSongs = [...songs].sort((a, b) => { + // Filter and sort songs + const filteredSongs = songs.filter(song => + song.title.toLowerCase().includes(searchQuery.toLowerCase()) || + song.artist.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const sortedSongs = [...filteredSongs].sort((a, b) => { const valA = a[sortField].toLowerCase(); const valB = b[sortField].toLowerCase(); @@ -123,6 +147,16 @@ export default function AdminPage() { return 0; }); + // Pagination + const totalPages = Math.ceil(sortedSongs.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const paginatedSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage); + + // Reset to page 1 when search changes + useEffect(() => { + setCurrentPage(1); + }, [searchQuery]); + if (!isAuthenticated) { return (
@@ -148,7 +182,7 @@ export default function AdminPage() {

Upload New Song

- +
-
- - setTitle(e.target.value)} - className="form-input" - /> -
-
- - setArtist(e.target.value)} - className="form-input" - /> -
@@ -184,6 +200,18 @@ export default function AdminPage() {

Song Library

+ + {/* Search */} +
+ setSearchQuery(e.target.value)} + className="form-input" + /> +
+
@@ -201,12 +229,12 @@ export default function AdminPage() { > Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')} - + - {sortedSongs.map(song => ( + {paginatedSongs.map(song => ( @@ -230,20 +258,22 @@ export default function AdminPage() { style={{ padding: '0.25rem' }} /> - + @@ -252,29 +282,74 @@ export default function AdminPage() { <> - + )} ))} - {songs.length === 0 && ( + {paginatedSongs.length === 0 && ( )}
FilenameActivations Actions
{song.id}{song.filename}{song.activations}
{song.title} {song.artist}{song.filename}{song.activations} - +
+ + +
- No songs uploaded yet. + {searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )}
); diff --git a/app/api/daily/route.ts b/app/api/daily/route.ts index 41b8010..97a234f 100644 --- a/app/api/daily/route.ts +++ b/app/api/daily/route.ts @@ -13,55 +13,56 @@ export async function GET() { }); if (!dailyPuzzle) { - // Find a random song to set as today's puzzle - const songsCount = await prisma.song.count(); - if (songsCount === 0) { + // Get all songs with their usage count + const allSongs = await prisma.song.findMany({ + include: { + puzzles: true, + }, + }); + + if (allSongs.length === 0) { return NextResponse.json({ error: 'No songs available' }, { status: 404 }); } - const skip = Math.floor(Math.random() * songsCount); - const randomSong = await prisma.song.findFirst({ - skip: skip, - }); + // Calculate weights: songs never used get weight 1.0, + // songs used once get 0.5, twice 0.33, etc. + const weightedSongs = allSongs.map(song => ({ + song, + weight: 1.0 / (song.puzzles.length + 1), + })); - if (randomSong) { - dailyPuzzle = await prisma.dailyPuzzle.create({ - data: { - date: today, - songId: randomSong.id, - }, - include: { song: true }, - }); + // Calculate total weight + const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0); + + // Pick a random song based on weights + let random = Math.random() * totalWeight; + let selectedSong = weightedSongs[0].song; + + for (const item of weightedSongs) { + random -= item.weight; + if (random <= 0) { + selectedSong = item.song; + break; + } } + + // Create the daily puzzle + dailyPuzzle = await prisma.dailyPuzzle.create({ + data: { + date: today, + songId: selectedSong.id, + }, + include: { song: true }, + }); } if (!dailyPuzzle) { return NextResponse.json({ error: 'Failed to create puzzle' }, { status: 500 }); } - // Return only necessary info to client (hide title/artist initially if we want strict security, - // but for this app we might need it for validation or just return the audio URL and ID) - // Actually, we should probably NOT return the title/artist here if we want to prevent cheating via network tab, - // but the requirement says "guess the title", so we need to validate on server or client. - // For simplicity in this prototype, we'll return the ID and audio URL. - // Validation can happen in a separate "guess" endpoint or client-side if we trust the user not to inspect too much. - // Let's return the audio URL. The client will request the full song info ONLY when they give up or guess correctly? - // Or we can just return the ID and have a separate "check" endpoint. - // For now, let's return the ID and the filename (public URL). - return NextResponse.json({ id: dailyPuzzle.id, audioUrl: `/uploads/${dailyPuzzle.song.filename}`, - // We might need a hash or something to validate guesses without revealing the answer, - // but for now let's keep it simple. The client needs to know if the guess is correct. - // We can send the answer hash? Or just handle checking on the client for now (easiest but insecure). - // Let's send the answer for now, assuming this is a fun app not a competitive e-sport. - // Wait, if I send the answer, it's too easy to cheat. - // Better: The client sends a guess, the server validates. - // But the requirements didn't specify a complex backend validation. - // Let's stick to: Client gets audio. Client has a list of all songs (for autocomplete). - // Client checks if selected song ID matches the daily puzzle song ID. - // So we need to return the song ID. songId: dailyPuzzle.songId }); diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts index b445043..b29ae8f 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; -import { writeFile } from 'fs/promises'; +import { writeFile, unlink } from 'fs/promises'; import path from 'path'; import { parseBuffer } from 'music-metadata'; @@ -9,23 +9,30 @@ const prisma = new PrismaClient(); export async function GET() { const songs = await prisma.song.findMany({ orderBy: { createdAt: 'desc' }, - select: { - id: true, - title: true, - artist: true, - filename: true, - createdAt: true, - } + include: { + puzzles: true, + }, }); - return NextResponse.json(songs); + + // Map to include activation count + const songsWithActivations = songs.map(song => ({ + id: song.id, + title: song.title, + artist: song.artist, + filename: song.filename, + createdAt: song.createdAt, + activations: song.puzzles.length, + })); + + return NextResponse.json(songsWithActivations); } export async function POST(request: Request) { try { const formData = await request.formData(); const file = formData.get('file') as File; - let title = formData.get('title') as string; - let artist = formData.get('artist') as string; + let title = ''; + let artist = ''; if (!file) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }); @@ -33,19 +40,17 @@ export async function POST(request: Request) { const buffer = Buffer.from(await file.arrayBuffer()); - // Try to extract metadata if title or artist are missing - if (!title || !artist) { - try { - const metadata = await parseBuffer(buffer, file.type); - if (!title && metadata.common.title) { - title = metadata.common.title; - } - if (!artist && metadata.common.artist) { - artist = metadata.common.artist; - } - } catch (e) { - console.error('Failed to parse metadata:', e); + // Extract metadata from file + try { + const metadata = await parseBuffer(buffer, file.type); + if (metadata.common.title) { + title = metadata.common.title; } + if (metadata.common.artist) { + artist = metadata.common.artist; + } + } catch (e) { + console.error('Failed to parse metadata:', e); } // Fallback if still missing @@ -91,3 +96,41 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); } } + +export async function DELETE(request: Request) { + try { + const { id } = await request.json(); + + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + // Get song to find filename + const song = await prisma.song.findUnique({ + where: { id: Number(id) }, + }); + + if (!song) { + return NextResponse.json({ error: 'Song not found' }, { status: 404 }); + } + + // Delete file + const filePath = path.join(process.cwd(), 'public/uploads', song.filename); + try { + await unlink(filePath); + } catch (e) { + console.error('Failed to delete file:', e); + // Continue with DB deletion even if file deletion fails + } + + // Delete from database (will cascade delete related puzzles) + await prisma.song.delete({ + where: { id: Number(id) }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting song:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..ac2879f 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ