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:
Hördle Bot
2025-11-23 00:50:35 +01:00
parent 4f088305df
commit 587fa59b79
8 changed files with 564 additions and 23 deletions

View 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>
);
}