From 4f088305df041794e49d21dbc245dd90352376d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 00:48:57 +0100 Subject: [PATCH 01/10] fix(admin): add Curate button linking to Special curation page --- app/admin/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ))} 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 02/10] 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 From 5944c1461481437a9a3b2f0cb1a4f47b36d12791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 00:53:36 +0100 Subject: [PATCH 03/10] fix(api): await params in Next.js 15 dynamic routes --- app/api/specials/[id]/route.ts | 10 ++++++---- app/api/specials/[id]/songs/route.ts | 15 +++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/api/specials/[id]/route.ts b/app/api/specials/[id]/route.ts index 8b1fcb8..41383fd 100644 --- a/app/api/specials/[id]/route.ts +++ b/app/api/specials/[id]/route.ts @@ -5,10 +5,11 @@ const prisma = new PrismaClient(); export async function GET( request: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const specialId = parseInt(params.id); + const { id } = await params; + const specialId = parseInt(id); const special = await prisma.special.findUnique({ where: { id: specialId }, @@ -37,10 +38,11 @@ export async function GET( export async function PUT( request: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const specialId = parseInt(params.id); + const { id } = await params; + const specialId = parseInt(id); const { name, maxAttempts, unlockSteps } = await request.json(); const special = await prisma.special.update({ diff --git a/app/api/specials/[id]/songs/route.ts b/app/api/specials/[id]/songs/route.ts index 4f40e9d..3d941b6 100644 --- a/app/api/specials/[id]/songs/route.ts +++ b/app/api/specials/[id]/songs/route.ts @@ -5,10 +5,11 @@ const prisma = new PrismaClient(); export async function POST( request: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const specialId = parseInt(params.id); + const { id } = await params; + const specialId = parseInt(id); const { songId, startTime = 0, order } = await request.json(); const specialSong = await prisma.specialSong.create({ @@ -32,10 +33,11 @@ export async function POST( export async function PUT( request: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const specialId = parseInt(params.id); + const { id } = await params; + const specialId = parseInt(id); const { songId, startTime, order } = await request.json(); const specialSong = await prisma.specialSong.update({ @@ -63,10 +65,11 @@ export async function PUT( export async function DELETE( request: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const specialId = parseInt(params.id); + const { id } = await params; + const specialId = parseInt(id); const { songId } = await request.json(); await prisma.specialSong.delete({ From b27d5e49c9fddbed6630831d7ab9a41477fd59b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 00:57:02 +0100 Subject: [PATCH 04/10] fix(api): update songs endpoint to work with SpecialSong model --- app/api/songs/route.ts | 55 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 8 deletions(-) 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); From 86829af17d9977d6d114d9453a9c0df04c096d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 01:03:03 +0100 Subject: [PATCH 05/10] feat(waveform): add segment markers, zoom, and individual segment playback --- app/admin/specials/[id]/page.tsx | 1 + components/WaveformEditor.tsx | 201 +++++++++++++++++++++++++++---- 2 files changed, 180 insertions(+), 22 deletions(-) diff --git a/app/admin/specials/[id]/page.tsx b/app/admin/specials/[id]/page.tsx index ae32b15..8219d9e 100644 --- a/app/admin/specials/[id]/page.tsx +++ b/app/admin/specials/[id]/page.tsx @@ -183,6 +183,7 @@ export default function SpecialEditorPage() { audioUrl={`/uploads/${selectedSpecialSong.song.filename}`} startTime={selectedSpecialSong.startTime} duration={totalDuration} + unlockSteps={unlockSteps} onStartTimeChange={(newStartTime) => handleStartTimeChange(selectedSpecialSong.songId, newStartTime)} /> {saving && ( diff --git a/components/WaveformEditor.tsx b/components/WaveformEditor.tsx index 5aed8f2..c949f16 100644 --- a/components/WaveformEditor.tsx +++ b/components/WaveformEditor.tsx @@ -6,15 +6,18 @@ 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, onStartTimeChange }: WaveformEditorProps) { +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 [isDragging, setIsDragging] = useState(false); 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 audioContextRef = useRef(null); const sourceRef = useRef(null); @@ -54,21 +57,28 @@ export default function WaveformEditor({ audioUrl, startTime, duration, onStartT 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 + // Draw waveform for visible range const data = audioBuffer.getChannelData(0); - const step = Math.ceil(data.length / width); + 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; - for (let j = 0; j < step; j++) { - const datum = data[(i * step) + j]; + 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; } @@ -76,25 +86,57 @@ export default function WaveformEditor({ audioUrl, startTime, duration, onStartT } // Draw selection overlay - const selectionStart = (startTime / audioDuration) * width; - const selectionWidth = (duration / audioDuration) * width; + const selectionStartPx = ((startTime - visibleStart) / visibleDuration) * width; + const selectionWidthPx = (duration / visibleDuration) * width; - ctx.fillStyle = 'rgba(79, 70, 229, 0.3)'; - ctx.fillRect(selectionStart, 0, selectionWidth, height); + 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(selectionStart, 0, selectionWidth, height); + // Draw selection borders + ctx.strokeStyle = '#4f46e5'; + ctx.lineWidth = 2; + ctx.strokeRect(Math.max(0, selectionStartPx), 0, Math.min(selectionWidthPx, width - selectionStartPx), height); + } - }, [audioBuffer, startTime, duration, audioDuration]); + // 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([]); + + }, [audioBuffer, startTime, duration, audioDuration, zoom, viewOffset, unlockSteps]); 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; + 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); @@ -105,12 +147,41 @@ export default function WaveformEditor({ audioUrl, startTime, duration, onStartT onStartTimeChange(Math.floor(newStartTime)); }; - const handlePlayPause = () => { + const stopPlayback = () => { + sourceRef.current?.stop(); + setIsPlaying(false); + setPlayingSegment(null); + }; + + 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); + + source.start(0, segmentStart, segmentDuration); + sourceRef.current = source; + setIsPlaying(true); + setPlayingSegment(segmentIndex); + + source.onended = () => { + setIsPlaying(false); + setPlayingSegment(null); + }; + }; + + const handlePlayFull = () => { if (!audioBuffer || !audioContextRef.current) return; if (isPlaying) { - sourceRef.current?.stop(); - setIsPlaying(false); + stopPlayback(); } else { const source = audioContextRef.current.createBufferSource(); source.buffer = audioBuffer; @@ -125,8 +196,64 @@ export default function WaveformEditor({ audioUrl, startTime, duration, onStartT } }; + 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 ( + + ); + })} +
); } From 23c26974245230bee06d0ddba7e0d8fed7a8db3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 01:06:19 +0100 Subject: [PATCH 06/10] feat(waveform): add playback cursor showing current position --- components/WaveformEditor.tsx | 69 ++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/components/WaveformEditor.tsx b/components/WaveformEditor.tsx index c949f16..1b59adb 100644 --- a/components/WaveformEditor.tsx +++ b/components/WaveformEditor.tsx @@ -18,8 +18,12 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt 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 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 () => { @@ -44,6 +48,9 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt if (sourceRef.current) { sourceRef.current.stop(); } + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } }; }, [audioUrl]); @@ -127,7 +134,29 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt }); ctx.setLineDash([]); - }, [audioBuffer, startTime, duration, audioDuration, zoom, viewOffset, unlockSteps]); + // 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]); const handleCanvasClick = (e: React.MouseEvent) => { if (!canvasRef.current || !audioDuration) return; @@ -151,6 +180,21 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt sourceRef.current?.stop(); setIsPlaying(false); setPlayingSegment(null); + setPlaybackPosition(null); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + + const updatePlaybackPosition = () => { + if (!audioContextRef.current || !isPlaying) return; + + const elapsed = audioContextRef.current.currentTime - playbackStartTimeRef.current; + const currentPos = playbackOffsetRef.current + elapsed; + setPlaybackPosition(currentPos); + + animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition); }; const handlePlaySegment = (segmentIndex: number) => { @@ -166,14 +210,25 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt 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); + + animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition); source.onended = () => { setIsPlaying(false); setPlayingSegment(null); + setPlaybackPosition(null); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } }; }; @@ -186,12 +241,24 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt 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); + + animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition); source.onended = () => { setIsPlaying(false); + setPlaybackPosition(null); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } }; } }; From e06e0d2919d4533334e8bbba5f3c36b8edca8ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 01:07:53 +0100 Subject: [PATCH 07/10] feat(special-editor): add manual save button with unsaved changes indicator --- app/admin/specials/[id]/page.tsx | 53 +++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/app/admin/specials/[id]/page.tsx b/app/admin/specials/[id]/page.tsx index 8219d9e..493569e 100644 --- a/app/admin/specials/[id]/page.tsx +++ b/app/admin/specials/[id]/page.tsx @@ -36,6 +36,8 @@ export default function SpecialEditorPage() { 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(); @@ -49,6 +51,8 @@ export default function SpecialEditorPage() { 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) { @@ -58,15 +62,20 @@ export default function SpecialEditorPage() { } }; - const handleStartTimeChange = async (songId: number, newStartTime: number) => { - if (!special) return; + 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, startTime: newStartTime }) + body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime }) }); if (res.ok) { @@ -76,10 +85,12 @@ export default function SpecialEditorPage() { return { ...prev, songs: prev.songs.map(ss => - ss.songId === songId ? { ...ss, startTime: newStartTime } : 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); @@ -176,21 +187,35 @@ export default function SpecialEditorPage() { Curate: {selectedSpecialSong.song.title}
-

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

+
+

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

+ +
handleStartTimeChange(selectedSpecialSong.songId, newStartTime)} + onStartTimeChange={handleStartTimeChange} /> - {saving && ( -
- Saving... -
- )}
)} From 54f47a947035c7a55062bd4d297b9dec25b49eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 01:10:04 +0100 Subject: [PATCH 08/10] fix(waveform): fix playback cursor animation using useEffect --- components/WaveformEditor.tsx | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/components/WaveformEditor.tsx b/components/WaveformEditor.tsx index 1b59adb..972ef7a 100644 --- a/components/WaveformEditor.tsx +++ b/components/WaveformEditor.tsx @@ -187,15 +187,30 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt } }; - const updatePlaybackPosition = () => { - if (!audioContextRef.current || !isPlaying) return; + // Animation loop for playback cursor + useEffect(() => { + if (!isPlaying || !audioContextRef.current) { + return; + } - const elapsed = audioContextRef.current.currentTime - playbackStartTimeRef.current; - const currentPos = playbackOffsetRef.current + elapsed; - setPlaybackPosition(currentPos); + const animate = () => { + if (!audioContextRef.current || !isPlaying) return; - animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition); - }; + 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; @@ -219,8 +234,6 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt setPlayingSegment(segmentIndex); setPlaybackPosition(segmentStart); - animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition); - source.onended = () => { setIsPlaying(false); setPlayingSegment(null); @@ -250,8 +263,6 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt setIsPlaying(true); setPlaybackPosition(startTime); - animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition); - source.onended = () => { setIsPlaying(false); setPlaybackPosition(null); From ec885212a5ec6eab18f6c00fe430ed4f04fd31be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 01:18:59 +0100 Subject: [PATCH 09/10] feat(waveform): add live hover preview for selection positioning --- components/WaveformEditor.tsx | 43 ++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/components/WaveformEditor.tsx b/components/WaveformEditor.tsx index 972ef7a..469b0a5 100644 --- a/components/WaveformEditor.tsx +++ b/components/WaveformEditor.tsx @@ -19,6 +19,7 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt 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 @@ -134,6 +135,24 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt }); 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; @@ -156,7 +175,7 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt } } - }, [audioBuffer, startTime, duration, audioDuration, zoom, viewOffset, unlockSteps, playbackPosition]); + }, [audioBuffer, startTime, duration, audioDuration, zoom, viewOffset, unlockSteps, playbackPosition, hoverPreviewTime]); const handleCanvasClick = (e: React.MouseEvent) => { if (!canvasRef.current || !audioDuration) return; @@ -176,6 +195,26 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt 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); @@ -337,6 +376,8 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt width={800} height={150} onClick={handleCanvasClick} + onMouseMove={handleCanvasMouseMove} + onMouseLeave={handleCanvasMouseLeave} style={{ width: '100%', height: 'auto', From fb911ccf4c58129eb78f19722576796e9473f14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sun, 23 Nov 2025 01:23:49 +0100 Subject: [PATCH 10/10] feat(migration): add migration for SpecialSong model --- .../migration.sql | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 prisma/migrations/20251123012308_add_special_song_model/migration.sql 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");