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
This commit is contained in:
200
app/admin/specials/[id]/page.tsx
Normal file
200
app/admin/specials/[id]/page.tsx
Normal file
@@ -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<Special | null>(null);
|
||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(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 (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!special) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>Special not found</p>
|
||||
<button onClick={() => router.push('/admin')}>Back to Admin</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
|
||||
const unlockSteps = JSON.parse(special.unlockSteps);
|
||||
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<button
|
||||
onClick={() => router.push('/admin')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '1rem'
|
||||
}}
|
||||
>
|
||||
← Back to Admin
|
||||
</button>
|
||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
Edit Special: {special.name}
|
||||
</h1>
|
||||
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
||||
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{special.songs.length === 0 ? (
|
||||
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
||||
<p>No songs assigned to this special yet.</p>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||||
Go back to the admin dashboard to add songs to this special.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
Select Song to Curate
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
||||
{special.songs.map(ss => (
|
||||
<div
|
||||
key={ss.songId}
|
||||
onClick={() => 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'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
||||
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
||||
Start: {ss.startTime}s
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSpecialSong && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
Curate: {selectedSpecialSong.song.title}
|
||||
</h2>
|
||||
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', marginBottom: '1rem' }}>
|
||||
Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.
|
||||
</p>
|
||||
<WaveformEditor
|
||||
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
|
||||
startTime={selectedSpecialSong.startTime}
|
||||
duration={totalDuration}
|
||||
onStartTimeChange={(newStartTime) => handleStartTimeChange(selectedSpecialSong.songId, newStartTime)}
|
||||
/>
|
||||
{saving && (
|
||||
<div style={{ marginTop: '1rem', color: '#4f46e5', fontSize: '0.875rem' }}>
|
||||
Saving...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
app/api/specials/[id]/route.ts
Normal file
60
app/api/specials/[id]/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
86
app/api/specials/[id]/songs/route.ts
Normal file
86
app/api/specials/[id]/songs/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLAudioElement>(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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<AudioPlayer
|
||||
src={dailyPuzzle.audioUrl}
|
||||
unlockedSeconds={unlockedSeconds}
|
||||
startTime={dailyPuzzle.startTime}
|
||||
autoPlay={lastAction === 'SKIP'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
164
components/WaveformEditor.tsx
Normal file
164
components/WaveformEditor.tsx
Normal file
@@ -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<HTMLCanvasElement>(null);
|
||||
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
|
||||
const [audioDuration, setAudioDuration] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const sourceRef = useRef<AudioBufferSourceNode | null>(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<HTMLCanvasElement>) => {
|
||||
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 (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={800}
|
||||
height={150}
|
||||
onClick={handleCanvasClick}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem'
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#4f46e5',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{isPlaying ? '⏸ Pause' : '▶ Play Selection'}
|
||||
</button>
|
||||
<div style={{ fontSize: '0.875rem', color: '#666' }}>
|
||||
Start: {startTime}s | Duration: {duration}s | Total: {Math.floor(audioDuration)}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user