diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c5dcefb..b8c6dc5 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -690,7 +690,7 @@ export default function AdminPage() { fontSize: '0.875rem' }}> {special.name} ({special._count?.songs || 0}) - + Curate ))} diff --git a/app/admin/specials/[id]/page.tsx b/app/admin/specials/[id]/page.tsx new file mode 100644 index 0000000..493569e --- /dev/null +++ b/app/admin/specials/[id]/page.tsx @@ -0,0 +1,226 @@ +'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); + const [pendingStartTime, setPendingStartTime] = useState(null); + const [hasUnsavedChanges, setHasUnsavedChanges] = 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); + // Initialize pendingStartTime with the current startTime of the first song + setPendingStartTime(data.songs[0].startTime); + } + } + } catch (error) { + console.error('Error fetching special:', error); + } finally { + setLoading(false); + } + }; + + const handleStartTimeChange = (newStartTime: number) => { + setPendingStartTime(newStartTime); + setHasUnsavedChanges(true); + }; + + const handleSave = async () => { + if (!special || !selectedSongId || pendingStartTime === null) return; + + setSaving(true); + try { + const res = await fetch(`/api/specials/${specialId}/songs`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime }) + }); + + if (res.ok) { + // Update local state + setSpecial(prev => { + if (!prev) return prev; + return { + ...prev, + songs: prev.songs.map(ss => + ss.songId === selectedSongId ? { ...ss, startTime: pendingStartTime } : ss + ) + }; + }); + setHasUnsavedChanges(false); + setPendingStartTime(null); // Reset pending state after saving + } + } 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. +

+ +
+ +
+
+ )} +
+ )} +
+ ); +} diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts index 356998d..97e2567 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -12,11 +12,15 @@ export async function GET() { include: { puzzles: true, genres: true, - specials: true, + specials: { + include: { + special: true + } + }, }, }); - // Map to include activation count + // Map to include activation count and flatten specials const songsWithActivations = songs.map(song => ({ id: song.id, title: song.title, @@ -27,7 +31,7 @@ export async function GET() { activations: song.puzzles.length, puzzles: song.puzzles, genres: song.genres, - specials: song.specials, + specials: song.specials.map(ss => ss.special), })); return NextResponse.json(songsWithActivations); @@ -178,16 +182,51 @@ export async function PUT(request: Request) { }; } - if (specialIds) { - data.specials = { - set: specialIds.map((sId: number) => ({ id: sId })) - }; + // Handle SpecialSong relations separately + if (specialIds !== undefined) { + // First, get current special assignments + const currentSpecials = await prisma.specialSong.findMany({ + where: { songId: Number(id) } + }); + + const currentSpecialIds = currentSpecials.map(ss => ss.specialId); + const newSpecialIds = specialIds as number[]; + + // Delete removed specials + const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid)); + if (toDelete.length > 0) { + await prisma.specialSong.deleteMany({ + where: { + songId: Number(id), + specialId: { in: toDelete } + } + }); + } + + // Add new specials + const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid)); + if (toAdd.length > 0) { + await prisma.specialSong.createMany({ + data: toAdd.map(specialId => ({ + songId: Number(id), + specialId, + startTime: 0 + })) + }); + } } const updatedSong = await prisma.song.update({ where: { id: Number(id) }, data, - include: { genres: true, specials: true } + include: { + genres: true, + specials: { + include: { + special: true + } + } + } }); return NextResponse.json(updatedSong); diff --git a/app/api/specials/[id]/route.ts b/app/api/specials/[id]/route.ts new file mode 100644 index 0000000..41383fd --- /dev/null +++ b/app/api/specials/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const specialId = parseInt(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: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const specialId = parseInt(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..3d941b6 --- /dev/null +++ b/app/api/specials/[id]/songs/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const specialId = parseInt(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: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const specialId = parseInt(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: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const specialId = parseInt(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..469b0a5 --- /dev/null +++ b/components/WaveformEditor.tsx @@ -0,0 +1,440 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +interface WaveformEditorProps { + audioUrl: string; + startTime: number; + duration: number; // Total puzzle duration (e.g., 60s) + unlockSteps: number[]; // e.g., [2, 4, 7, 11, 16, 30, 60] + onStartTimeChange: (newStartTime: number) => void; +} + +export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) { + const canvasRef = useRef(null); + const [audioBuffer, setAudioBuffer] = useState(null); + const [audioDuration, setAudioDuration] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [playingSegment, setPlayingSegment] = useState(null); + const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in + const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning + const [playbackPosition, setPlaybackPosition] = useState(null); // Current playback position in seconds + const [hoverPreviewTime, setHoverPreviewTime] = useState(null); // Preview position on hover + const audioContextRef = useRef(null); + const sourceRef = useRef(null); + const playbackStartTimeRef = useRef(0); // When playback started + const playbackOffsetRef = useRef(0); // Offset in the audio file + const animationFrameRef = 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(); + } + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [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; + + // Calculate visible range based on zoom and offset + const visibleDuration = audioDuration / zoom; + const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration)); + const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration); + + // Clear canvas + ctx.fillStyle = '#f3f4f6'; + ctx.fillRect(0, 0, width, height); + + // Draw waveform for visible range + const data = audioBuffer.getChannelData(0); + const samplesPerPixel = Math.ceil((data.length * visibleDuration / audioDuration) / width); + const startSample = Math.floor(data.length * visibleStart / audioDuration); + const amp = height / 2; + + ctx.fillStyle = '#4f46e5'; + for (let i = 0; i < width; i++) { + let min = 1.0; + let max = -1.0; + const sampleIndex = startSample + (i * samplesPerPixel); + for (let j = 0; j < samplesPerPixel && sampleIndex + j < data.length; j++) { + const datum = data[sampleIndex + 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 selectionStartPx = ((startTime - visibleStart) / visibleDuration) * width; + const selectionWidthPx = (duration / visibleDuration) * width; + + if (selectionStartPx + selectionWidthPx > 0 && selectionStartPx < width) { + ctx.fillStyle = 'rgba(79, 70, 229, 0.3)'; + ctx.fillRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height); + + // Draw selection borders + ctx.strokeStyle = '#4f46e5'; + ctx.lineWidth = 2; + ctx.strokeRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height); + } + + // Draw segment markers (vertical lines) + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + + let cumulativeTime = 0; + unlockSteps.forEach((step, index) => { + const segmentTime = startTime + cumulativeTime; + const segmentPx = ((segmentTime - visibleStart) / visibleDuration) * width; + + if (segmentPx >= 0 && segmentPx <= width) { + ctx.beginPath(); + ctx.moveTo(segmentPx, 0); + ctx.lineTo(segmentPx, height); + ctx.stroke(); + + // Draw segment number + ctx.setLineDash([]); + ctx.fillStyle = '#ef4444'; + ctx.font = 'bold 12px sans-serif'; + ctx.fillText(`${index + 1}`, segmentPx + 3, 15); + ctx.setLineDash([5, 5]); + } + + cumulativeTime = step; + }); + ctx.setLineDash([]); + + // Draw hover preview (semi-transparent) + if (hoverPreviewTime !== null) { + const previewStartPx = ((hoverPreviewTime - visibleStart) / visibleDuration) * width; + const previewWidthPx = (duration / visibleDuration) * width; + + if (previewStartPx + previewWidthPx > 0 && previewStartPx < width) { + ctx.fillStyle = 'rgba(16, 185, 129, 0.2)'; // Light green + ctx.fillRect(Math.max(0, previewStartPx), 0, Math.min(previewWidthPx, width - previewStartPx), height); + + // Draw preview borders + ctx.strokeStyle = '#10b981'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + ctx.strokeRect(Math.max(0, previewStartPx), 0, Math.min(previewWidthPx, width - previewStartPx), height); + ctx.setLineDash([]); + } + } + + // Draw playback cursor + if (playbackPosition !== null) { + const cursorPx = ((playbackPosition - visibleStart) / visibleDuration) * width; + if (cursorPx >= 0 && cursorPx <= width) { + ctx.strokeStyle = '#10b981'; // Green + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cursorPx, 0); + ctx.lineTo(cursorPx, height); + ctx.stroke(); + + // Draw playhead triangle + ctx.fillStyle = '#10b981'; + ctx.beginPath(); + ctx.moveTo(cursorPx, 0); + ctx.lineTo(cursorPx - 5, 10); + ctx.lineTo(cursorPx + 5, 10); + ctx.closePath(); + ctx.fill(); + } + } + + }, [audioBuffer, startTime, duration, audioDuration, zoom, viewOffset, unlockSteps, playbackPosition, hoverPreviewTime]); + + const handleCanvasClick = (e: React.MouseEvent) => { + if (!canvasRef.current || !audioDuration) return; + + const rect = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const visibleDuration = audioDuration / zoom; + const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration)); + const clickedTime = visibleStart + (x / rect.width) * visibleDuration; + + // 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 handleCanvasMouseMove = (e: React.MouseEvent) => { + if (!canvasRef.current || !audioDuration) return; + + const rect = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const visibleDuration = audioDuration / zoom; + const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration)); + const hoveredTime = visibleStart + (x / rect.width) * visibleDuration; + + // Calculate where the selection would be centered on this point + let previewStartTime = hoveredTime - (duration / 2); + previewStartTime = Math.max(0, Math.min(previewStartTime, audioDuration - duration)); + + setHoverPreviewTime(previewStartTime); + }; + + const handleCanvasMouseLeave = () => { + setHoverPreviewTime(null); + }; + + const stopPlayback = () => { + sourceRef.current?.stop(); + setIsPlaying(false); + setPlayingSegment(null); + setPlaybackPosition(null); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + + // Animation loop for playback cursor + useEffect(() => { + if (!isPlaying || !audioContextRef.current) { + return; + } + + const animate = () => { + if (!audioContextRef.current || !isPlaying) return; + + const elapsed = audioContextRef.current.currentTime - playbackStartTimeRef.current; + const currentPos = playbackOffsetRef.current + elapsed; + setPlaybackPosition(currentPos); + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isPlaying]); + + const handlePlaySegment = (segmentIndex: number) => { + if (!audioBuffer || !audioContextRef.current) return; + + stopPlayback(); + + const source = audioContextRef.current.createBufferSource(); + source.buffer = audioBuffer; + source.connect(audioContextRef.current.destination); + + // Calculate segment start and duration + const segmentStart = startTime + (segmentIndex > 0 ? unlockSteps[segmentIndex - 1] : 0); + const segmentDuration = unlockSteps[segmentIndex] - (segmentIndex > 0 ? unlockSteps[segmentIndex - 1] : 0); + + playbackStartTimeRef.current = audioContextRef.current.currentTime; + playbackOffsetRef.current = segmentStart; + + source.start(0, segmentStart, segmentDuration); + sourceRef.current = source; + setIsPlaying(true); + setPlayingSegment(segmentIndex); + setPlaybackPosition(segmentStart); + + source.onended = () => { + setIsPlaying(false); + setPlayingSegment(null); + setPlaybackPosition(null); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + }; + + const handlePlayFull = () => { + if (!audioBuffer || !audioContextRef.current) return; + + if (isPlaying) { + stopPlayback(); + } else { + const source = audioContextRef.current.createBufferSource(); + source.buffer = audioBuffer; + source.connect(audioContextRef.current.destination); + + playbackStartTimeRef.current = audioContextRef.current.currentTime; + playbackOffsetRef.current = startTime; + + source.start(0, startTime, duration); + sourceRef.current = source; + setIsPlaying(true); + setPlaybackPosition(startTime); + + source.onended = () => { + setIsPlaying(false); + setPlaybackPosition(null); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + } + }; + + const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10)); + const handleZoomOut = () => setZoom(prev => Math.max(prev / 1.5, 1)); + const handlePanLeft = () => { + const visibleDuration = audioDuration / zoom; + setViewOffset(prev => Math.max(0, prev - visibleDuration * 0.2)); + }; + const handlePanRight = () => { + const visibleDuration = audioDuration / zoom; + setViewOffset(prev => Math.min(audioDuration - visibleDuration, prev + visibleDuration * 0.2)); + }; + + return ( +
+ {/* Zoom and Pan Controls */} +
+ + + Zoom: {zoom.toFixed(1)}x + {zoom > 1 && ( + <> + + + + )} +
+ + + + {/* Playback Controls */} +
+ + +
+ Start: {startTime}s | Duration: {duration}s | Total: {Math.floor(audioDuration)}s +
+
+ + {/* Segment Playback Buttons */} +
+ Play Segments: + {unlockSteps.map((step, index) => { + const segmentStart = index > 0 ? unlockSteps[index - 1] : 0; + const segmentDuration = step - segmentStart; + return ( + + ); + })} +
+
+ ); +} 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/migrations/20251123012308_add_special_song_model/migration.sql b/prisma/migrations/20251123012308_add_special_song_model/migration.sql new file mode 100644 index 0000000..89fb071 --- /dev/null +++ b/prisma/migrations/20251123012308_add_special_song_model/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "SpecialSong" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "specialId" INTEGER NOT NULL, + "songId" INTEGER NOT NULL, + "startTime" INTEGER NOT NULL DEFAULT 0, + "order" INTEGER, + CONSTRAINT "SpecialSong_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "SpecialSong_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Migrate data from _SongToSpecial to SpecialSong +INSERT INTO "SpecialSong" ("specialId", "songId", "startTime") +SELECT "B" as "specialId", "A" as "songId", 0 as "startTime" +FROM "_SongToSpecial"; + +-- DropTable +DROP TABLE "_SongToSpecial"; + +-- CreateIndex +CREATE UNIQUE INDEX "SpecialSong_specialId_songId_key" ON "SpecialSong"("specialId", "songId"); 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