From 587fa59b7912e2b64452289d25789f879479dc3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 00:50:35 +0100 Subject: [PATCH] feat(special-curation): complete implementation with all components - Database: SpecialSong model with startTime - Backend: API endpoints for curation - Admin: Waveform editor and curation page - Game: startTime support in AudioPlayer - UI: Curate button in admin dashboard --- app/admin/specials/[id]/page.tsx | 200 +++++++++++++++++++++++++++ app/api/specials/[id]/route.ts | 60 ++++++++ app/api/specials/[id]/songs/route.ts | 86 ++++++++++++ components/AudioPlayer.tsx | 14 +- components/Game.tsx | 2 + components/WaveformEditor.tsx | 164 ++++++++++++++++++++++ lib/dailyPuzzle.ts | 45 ++++-- prisma/schema.prisma | 16 ++- 8 files changed, 564 insertions(+), 23 deletions(-) create mode 100644 app/admin/specials/[id]/page.tsx create mode 100644 app/api/specials/[id]/route.ts create mode 100644 app/api/specials/[id]/songs/route.ts create mode 100644 components/WaveformEditor.tsx diff --git a/app/admin/specials/[id]/page.tsx b/app/admin/specials/[id]/page.tsx new file mode 100644 index 0000000..ae32b15 --- /dev/null +++ b/app/admin/specials/[id]/page.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import WaveformEditor from '@/components/WaveformEditor'; + +interface Song { + id: number; + title: string; + artist: string; + filename: string; +} + +interface SpecialSong { + id: number; + songId: number; + startTime: number; + order: number | null; + song: Song; +} + +interface Special { + id: number; + name: string; + maxAttempts: number; + unlockSteps: string; + songs: SpecialSong[]; +} + +export default function SpecialEditorPage() { + const params = useParams(); + const router = useRouter(); + const specialId = params.id as string; + + const [special, setSpecial] = useState(null); + const [selectedSongId, setSelectedSongId] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetchSpecial(); + }, [specialId]); + + const fetchSpecial = async () => { + try { + const res = await fetch(`/api/specials/${specialId}`); + if (res.ok) { + const data = await res.json(); + setSpecial(data); + if (data.songs.length > 0) { + setSelectedSongId(data.songs[0].songId); + } + } + } catch (error) { + console.error('Error fetching special:', error); + } finally { + setLoading(false); + } + }; + + const handleStartTimeChange = async (songId: number, newStartTime: number) => { + if (!special) return; + + setSaving(true); + try { + const res = await fetch(`/api/specials/${specialId}/songs`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ songId, startTime: newStartTime }) + }); + + if (res.ok) { + // Update local state + setSpecial(prev => { + if (!prev) return prev; + return { + ...prev, + songs: prev.songs.map(ss => + ss.songId === songId ? { ...ss, startTime: newStartTime } : ss + ) + }; + }); + } + } catch (error) { + console.error('Error updating start time:', error); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + if (!special) { + return ( +
+

Special not found

+ +
+ ); + } + + const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId); + const unlockSteps = JSON.parse(special.unlockSteps); + const totalDuration = unlockSteps[unlockSteps.length - 1]; + + return ( +
+
+ +

+ Edit Special: {special.name} +

+

+ Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s +

+
+ + {special.songs.length === 0 ? ( +
+

No songs assigned to this special yet.

+

+ Go back to the admin dashboard to add songs to this special. +

+
+ ) : ( +
+
+

+ Select Song to Curate +

+
+ {special.songs.map(ss => ( +
setSelectedSongId(ss.songId)} + style={{ + padding: '1rem', + background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6', + color: selectedSongId === ss.songId ? 'white' : 'black', + borderRadius: '0.5rem', + cursor: 'pointer', + border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent' + }} + > +
{ss.song.title}
+
{ss.song.artist}
+
+ Start: {ss.startTime}s +
+
+ ))} +
+
+ + {selectedSpecialSong && ( +
+

+ Curate: {selectedSpecialSong.song.title} +

+
+

+ Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear. +

+ handleStartTimeChange(selectedSpecialSong.songId, newStartTime)} + /> + {saving && ( +
+ Saving... +
+ )} +
+
+ )} +
+ )} +
+ ); +} diff --git a/app/api/specials/[id]/route.ts b/app/api/specials/[id]/route.ts new file mode 100644 index 0000000..8b1fcb8 --- /dev/null +++ b/app/api/specials/[id]/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + try { + const specialId = parseInt(params.id); + + const special = await prisma.special.findUnique({ + where: { id: specialId }, + include: { + songs: { + include: { + song: true + }, + orderBy: { + order: 'asc' + } + } + } + }); + + if (!special) { + return NextResponse.json({ error: 'Special not found' }, { status: 404 }); + } + + return NextResponse.json(special); + } catch (error) { + console.error('Error fetching special:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: { id: string } } +) { + try { + const specialId = parseInt(params.id); + const { name, maxAttempts, unlockSteps } = await request.json(); + + const special = await prisma.special.update({ + where: { id: specialId }, + data: { + name, + maxAttempts, + unlockSteps: JSON.stringify(unlockSteps) + } + }); + + return NextResponse.json(special); + } catch (error) { + console.error('Error updating special:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/specials/[id]/songs/route.ts b/app/api/specials/[id]/songs/route.ts new file mode 100644 index 0000000..4f40e9d --- /dev/null +++ b/app/api/specials/[id]/songs/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function POST( + request: Request, + { params }: { params: { id: string } } +) { + try { + const specialId = parseInt(params.id); + const { songId, startTime = 0, order } = await request.json(); + + const specialSong = await prisma.specialSong.create({ + data: { + specialId, + songId, + startTime, + order + }, + include: { + song: true + } + }); + + return NextResponse.json(specialSong); + } catch (error) { + console.error('Error adding song to special:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: { id: string } } +) { + try { + const specialId = parseInt(params.id); + const { songId, startTime, order } = await request.json(); + + const specialSong = await prisma.specialSong.update({ + where: { + specialId_songId: { + specialId, + songId + } + }, + data: { + startTime, + order + }, + include: { + song: true + } + }); + + return NextResponse.json(specialSong); + } catch (error) { + console.error('Error updating special song:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: { id: string } } +) { + try { + const specialId = parseInt(params.id); + const { songId } = await request.json(); + + await prisma.specialSong.delete({ + where: { + specialId_songId: { + specialId, + songId + } + } + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error removing song from special:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/components/AudioPlayer.tsx b/components/AudioPlayer.tsx index 2a0c10e..c9c98b5 100644 --- a/components/AudioPlayer.tsx +++ b/components/AudioPlayer.tsx @@ -5,11 +5,12 @@ import { useState, useRef, useEffect } from 'react'; interface AudioPlayerProps { src: string; unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length) + startTime?: number; // Start offset in seconds (for curated specials) onPlay?: () => void; autoPlay?: boolean; } -export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = false }: AudioPlayerProps) { +export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, autoPlay = false }: AudioPlayerProps) { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); @@ -17,7 +18,7 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f useEffect(() => { if (audioRef.current) { audioRef.current.pause(); - audioRef.current.currentTime = 0; + audioRef.current.currentTime = startTime; setIsPlaying(false); setProgress(0); @@ -36,7 +37,7 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f } } } - }, [src, unlockedSeconds, autoPlay]); + }, [src, unlockedSeconds, startTime, autoPlay]); const togglePlay = () => { if (!audioRef.current) return; @@ -54,12 +55,13 @@ export default function AudioPlayer({ src, unlockedSeconds, onPlay, autoPlay = f if (!audioRef.current) return; const current = audioRef.current.currentTime; - const percent = (current / unlockedSeconds) * 100; + const elapsed = current - startTime; + const percent = (elapsed / unlockedSeconds) * 100; setProgress(Math.min(percent, 100)); - if (current >= unlockedSeconds) { + if (elapsed >= unlockedSeconds) { audioRef.current.pause(); - audioRef.current.currentTime = 0; + audioRef.current.currentTime = startTime; setIsPlaying(false); setProgress(0); } diff --git a/components/Game.tsx b/components/Game.tsx index 3bac190..7760600 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -16,6 +16,7 @@ interface GameProps { title: string; artist: string; coverImage: string | null; + startTime?: number; } | null; genre?: string | null; isSpecial?: boolean; @@ -195,6 +196,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max diff --git a/components/WaveformEditor.tsx b/components/WaveformEditor.tsx new file mode 100644 index 0000000..5aed8f2 --- /dev/null +++ b/components/WaveformEditor.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +interface WaveformEditorProps { + audioUrl: string; + startTime: number; + duration: number; // Total puzzle duration (e.g., 60s) + onStartTimeChange: (newStartTime: number) => void; +} + +export default function WaveformEditor({ audioUrl, startTime, duration, onStartTimeChange }: WaveformEditorProps) { + const canvasRef = useRef(null); + const [audioBuffer, setAudioBuffer] = useState(null); + const [audioDuration, setAudioDuration] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const audioContextRef = useRef(null); + const sourceRef = useRef(null); + + useEffect(() => { + const loadAudio = async () => { + try { + const response = await fetch(audioUrl); + const arrayBuffer = await response.arrayBuffer(); + + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioContextRef.current = audioContext; + + const buffer = await audioContext.decodeAudioData(arrayBuffer); + setAudioBuffer(buffer); + setAudioDuration(buffer.duration); + } catch (error) { + console.error('Error loading audio:', error); + } + }; + + loadAudio(); + + return () => { + if (sourceRef.current) { + sourceRef.current.stop(); + } + }; + }, [audioUrl]); + + useEffect(() => { + if (!audioBuffer || !canvasRef.current) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const width = canvas.width; + const height = canvas.height; + + // Clear canvas + ctx.fillStyle = '#f3f4f6'; + ctx.fillRect(0, 0, width, height); + + // Draw waveform + const data = audioBuffer.getChannelData(0); + const step = Math.ceil(data.length / width); + const amp = height / 2; + + ctx.fillStyle = '#4f46e5'; + for (let i = 0; i < width; i++) { + let min = 1.0; + let max = -1.0; + for (let j = 0; j < step; j++) { + const datum = data[(i * step) + j]; + if (datum < min) min = datum; + if (datum > max) max = datum; + } + ctx.fillRect(i, (1 + min) * amp, 1, Math.max(1, (max - min) * amp)); + } + + // Draw selection overlay + const selectionStart = (startTime / audioDuration) * width; + const selectionWidth = (duration / audioDuration) * width; + + ctx.fillStyle = 'rgba(79, 70, 229, 0.3)'; + ctx.fillRect(selectionStart, 0, selectionWidth, height); + + // Draw selection borders + ctx.strokeStyle = '#4f46e5'; + ctx.lineWidth = 2; + ctx.strokeRect(selectionStart, 0, selectionWidth, height); + + }, [audioBuffer, startTime, duration, audioDuration]); + + const handleCanvasClick = (e: React.MouseEvent) => { + if (!canvasRef.current || !audioDuration) return; + + const rect = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const clickedTime = (x / rect.width) * audioDuration; + + // Center the selection on the clicked point + let newStartTime = clickedTime - (duration / 2); + + // Clamp to valid range + newStartTime = Math.max(0, Math.min(newStartTime, audioDuration - duration)); + + onStartTimeChange(Math.floor(newStartTime)); + }; + + const handlePlayPause = () => { + if (!audioBuffer || !audioContextRef.current) return; + + if (isPlaying) { + sourceRef.current?.stop(); + setIsPlaying(false); + } else { + const source = audioContextRef.current.createBufferSource(); + source.buffer = audioBuffer; + source.connect(audioContextRef.current.destination); + source.start(0, startTime, duration); + sourceRef.current = source; + setIsPlaying(true); + + source.onended = () => { + setIsPlaying(false); + }; + } + }; + + return ( +
+ +
+ +
+ Start: {startTime}s | Duration: {duration}s | Total: {Math.floor(audioDuration)}s +
+
+
+ ); +} diff --git a/lib/dailyPuzzle.ts b/lib/dailyPuzzle.ts index da46eab..fa76269 100644 --- a/lib/dailyPuzzle.ts +++ b/lib/dailyPuzzle.ts @@ -146,32 +146,36 @@ export async function getOrCreateSpecialPuzzle(specialName: string) { }); if (!dailyPuzzle) { - // Get songs available for this special - const allSongs = await prisma.song.findMany({ - where: { specials: { some: { id: special.id } } }, + // Get songs available for this special through SpecialSong + const specialSongs = await prisma.specialSong.findMany({ + where: { specialId: special.id }, include: { - puzzles: { - where: { specialId: special.id } - }, - }, + song: { + include: { + puzzles: { + where: { specialId: special.id } + } + } + } + } }); - if (allSongs.length === 0) return null; + if (specialSongs.length === 0) return null; // Calculate weights - const weightedSongs = allSongs.map(song => ({ - song, - weight: 1.0 / (song.puzzles.length + 1), + const weightedSongs = specialSongs.map(specialSong => ({ + specialSong, + weight: 1.0 / (specialSong.song.puzzles.length + 1), })); const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0); let random = Math.random() * totalWeight; - let selectedSong = weightedSongs[0].song; + let selectedSpecialSong = weightedSongs[0].specialSong; for (const item of weightedSongs) { random -= item.weight; if (random <= 0) { - selectedSong = item.song; + selectedSpecialSong = item.specialSong; break; } } @@ -180,7 +184,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) { dailyPuzzle = await prisma.dailyPuzzle.create({ data: { date: today, - songId: selectedSong.id, + songId: selectedSpecialSong.songId, specialId: special.id }, include: { song: true }, @@ -198,6 +202,16 @@ export async function getOrCreateSpecialPuzzle(specialName: string) { if (!dailyPuzzle) return null; + // Fetch the startTime from SpecialSong + const specialSong = await prisma.specialSong.findUnique({ + where: { + specialId_songId: { + specialId: special.id, + songId: dailyPuzzle.songId + } + } + }); + // Calculate puzzle number const puzzleCount = await prisma.dailyPuzzle.count({ where: { @@ -218,7 +232,8 @@ export async function getOrCreateSpecialPuzzle(specialName: string) { coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null, special: specialName, maxAttempts: special.maxAttempts, - unlockSteps: JSON.parse(special.unlockSteps) + unlockSteps: JSON.parse(special.unlockSteps), + startTime: specialSong?.startTime || 0 }; } catch (error) { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d14e0e7..b46456f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,7 +19,7 @@ model Song { createdAt DateTime @default(now()) puzzles DailyPuzzle[] genres Genre[] - specials Special[] + specials SpecialSong[] } model Genre { @@ -35,10 +35,22 @@ model Special { maxAttempts Int @default(7) unlockSteps String // JSON array: "[2,4,7,11,16,30,60]" createdAt DateTime @default(now()) - songs Song[] + songs SpecialSong[] dailyPuzzles DailyPuzzle[] } +model SpecialSong { + id Int @id @default(autoincrement()) + specialId Int + special Special @relation(fields: [specialId], references: [id], onDelete: Cascade) + songId Int + song Song @relation(fields: [songId], references: [id], onDelete: Cascade) + startTime Int @default(0) // Start time in seconds + order Int? // For manual ordering + + @@unique([specialId, songId]) +} + model DailyPuzzle { id Int @id @default(autoincrement()) date String // Format: YYYY-MM-DD