feat: Add cover art support and auto-migration
- Extract cover art from MP3s during upload - Display cover art in game result screens (win/loss) - Add coverImage field to Song model - Add migration script to backfill covers for existing songs - Configure Docker to run migration script on startup
This commit is contained in:
@@ -35,6 +35,10 @@ export default function AdminPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// Audio state
|
||||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
const res = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
@@ -132,6 +136,29 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayPause = (song: Song) => {
|
||||
if (playingSongId === song.id) {
|
||||
// Pause current song
|
||||
audioElement?.pause();
|
||||
setPlayingSongId(null);
|
||||
} else {
|
||||
// Stop any currently playing song
|
||||
audioElement?.pause();
|
||||
|
||||
// Play new song
|
||||
const audio = new Audio(`/uploads/${song.filename}`);
|
||||
audio.play();
|
||||
setAudioElement(audio);
|
||||
setPlayingSongId(song.id);
|
||||
|
||||
// Reset when song ends
|
||||
audio.onended = () => {
|
||||
setPlayingSongId(null);
|
||||
setAudioElement(null);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and sort songs
|
||||
const filteredSongs = songs.filter(song =>
|
||||
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -297,6 +324,13 @@ export default function AdminPage() {
|
||||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => handlePlayPause(song)}
|
||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||||
title={playingSongId === song.id ? "Pause" : "Play"}
|
||||
>
|
||||
{playingSongId === song.id ? '⏸️' : '▶️'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startEditing(song)}
|
||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function GET() {
|
||||
artist: song.artist,
|
||||
filename: song.filename,
|
||||
createdAt: song.createdAt,
|
||||
coverImage: song.coverImage,
|
||||
activations: song.puzzles.length,
|
||||
}));
|
||||
|
||||
@@ -62,11 +63,30 @@ export async function POST(request: Request) {
|
||||
|
||||
await writeFile(path.join(uploadDir, filename), buffer);
|
||||
|
||||
// Handle cover image
|
||||
let coverImage = null;
|
||||
try {
|
||||
const metadata = await parseBuffer(buffer, file.type);
|
||||
const picture = metadata.common.picture?.[0];
|
||||
|
||||
if (picture) {
|
||||
const extension = picture.format.split('/')[1] || 'jpg';
|
||||
const coverFilename = `cover-${Date.now()}.${extension}`;
|
||||
const coverPath = path.join(process.cwd(), 'public/uploads/covers', coverFilename);
|
||||
|
||||
await writeFile(coverPath, picture.data);
|
||||
coverImage = coverFilename;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to extract cover image:', e);
|
||||
}
|
||||
|
||||
const song = await prisma.song.create({
|
||||
data: {
|
||||
title,
|
||||
artist,
|
||||
filename,
|
||||
coverImage,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -123,6 +143,16 @@ export async function DELETE(request: Request) {
|
||||
// Continue with DB deletion even if file deletion fails
|
||||
}
|
||||
|
||||
// Delete cover image if exists
|
||||
if (song.coverImage) {
|
||||
const coverPath = path.join(process.cwd(), 'public/uploads/covers', song.coverImage);
|
||||
try {
|
||||
await unlink(coverPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete cover image:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database (will cascade delete related puzzles)
|
||||
await prisma.song.delete({
|
||||
where: { id: Number(id) },
|
||||
|
||||
@@ -40,7 +40,10 @@ async function getDailyPuzzle() {
|
||||
return {
|
||||
id: dailyPuzzle.id,
|
||||
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
|
||||
songId: dailyPuzzle.songId
|
||||
songId: dailyPuzzle.songId,
|
||||
title: dailyPuzzle.song.title,
|
||||
artist: dailyPuzzle.song.artist,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
Reference in New Issue
Block a user