462 lines
20 KiB
TypeScript
462 lines
20 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
|
||
interface Song {
|
||
id: number;
|
||
title: string;
|
||
artist: string;
|
||
filename: string;
|
||
createdAt: string;
|
||
activations: number;
|
||
}
|
||
|
||
type SortField = 'title' | 'artist' | 'createdAt';
|
||
type SortDirection = 'asc' | 'desc';
|
||
|
||
export default function AdminPage() {
|
||
const [password, setPassword] = useState('');
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [message, setMessage] = useState('');
|
||
const [songs, setSongs] = useState<Song[]>([]);
|
||
|
||
// Edit state
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
const [editTitle, setEditTitle] = useState('');
|
||
const [editArtist, setEditArtist] = useState('');
|
||
|
||
// Sort state
|
||
const [sortField, setSortField] = useState<SortField>('artist');
|
||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||
|
||
// Search and pagination state
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const itemsPerPage = 10;
|
||
|
||
// Audio state
|
||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||
|
||
// Check for existing auth on mount
|
||
useEffect(() => {
|
||
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
||
if (authToken === 'authenticated') {
|
||
setIsAuthenticated(true);
|
||
fetchSongs();
|
||
}
|
||
}, []);
|
||
|
||
const handleLogin = async () => {
|
||
const res = await fetch('/api/admin/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
if (res.ok) {
|
||
localStorage.setItem('hoerdle_admin_auth', 'authenticated');
|
||
setIsAuthenticated(true);
|
||
fetchSongs();
|
||
} else {
|
||
alert('Wrong password');
|
||
}
|
||
};
|
||
|
||
const fetchSongs = async () => {
|
||
const res = await fetch('/api/songs');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setSongs(data);
|
||
}
|
||
};
|
||
|
||
const handleUpload = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!file) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
setMessage('Uploading...');
|
||
const res = await fetch('/api/songs', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const validation = data.validation;
|
||
|
||
let statusMessage = '✅ Song uploaded successfully!\n\n';
|
||
statusMessage += `📊 Audio Info:\n`;
|
||
statusMessage += `• Format: ${validation.codec || 'unknown'}\n`;
|
||
statusMessage += `• Bitrate: ${Math.round(validation.bitrate / 1000)} kbps\n`;
|
||
statusMessage += `• Sample Rate: ${validation.sampleRate} Hz\n`;
|
||
statusMessage += `• Duration: ${Math.round(validation.duration)} seconds\n`;
|
||
statusMessage += `• Cover Art: ${validation.hasCover ? '✅ Yes' : '❌ No'}\n`;
|
||
|
||
if (validation.warnings.length > 0) {
|
||
statusMessage += `\n⚠️ Warnings:\n`;
|
||
validation.warnings.forEach((warning: string) => {
|
||
statusMessage += `• ${warning}\n`;
|
||
});
|
||
}
|
||
|
||
setMessage(statusMessage);
|
||
setFile(null);
|
||
fetchSongs();
|
||
} else {
|
||
setMessage('Upload failed.');
|
||
}
|
||
};
|
||
|
||
const startEditing = (song: Song) => {
|
||
setEditingId(song.id);
|
||
setEditTitle(song.title);
|
||
setEditArtist(song.artist);
|
||
};
|
||
|
||
const cancelEditing = () => {
|
||
setEditingId(null);
|
||
setEditTitle('');
|
||
setEditArtist('');
|
||
};
|
||
|
||
const saveEditing = async (id: number) => {
|
||
const res = await fetch('/api/songs', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id, title: editTitle, artist: editArtist }),
|
||
});
|
||
|
||
if (res.ok) {
|
||
setEditingId(null);
|
||
fetchSongs();
|
||
} else {
|
||
alert('Failed to update song');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number, title: string) => {
|
||
if (!confirm(`Are you sure you want to delete "${title}"? This will also delete the file.`)) {
|
||
return;
|
||
}
|
||
|
||
const res = await fetch('/api/songs', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
|
||
if (res.ok) {
|
||
fetchSongs();
|
||
} else {
|
||
alert('Failed to delete song');
|
||
}
|
||
};
|
||
|
||
const handleSort = (field: SortField) => {
|
||
if (sortField === field) {
|
||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField(field);
|
||
setSortDirection('asc');
|
||
}
|
||
};
|
||
|
||
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}`);
|
||
|
||
// Handle playback errors
|
||
audio.onerror = () => {
|
||
alert(`Failed to load audio file: ${song.filename}\nThe file may be corrupted or missing.`);
|
||
setPlayingSongId(null);
|
||
setAudioElement(null);
|
||
};
|
||
|
||
audio.play()
|
||
.then(() => {
|
||
setAudioElement(audio);
|
||
setPlayingSongId(song.id);
|
||
})
|
||
.catch((error) => {
|
||
console.error('Playback error:', error);
|
||
alert(`Failed to play audio: ${error.message}`);
|
||
setPlayingSongId(null);
|
||
setAudioElement(null);
|
||
});
|
||
|
||
// 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()) ||
|
||
song.artist.toLowerCase().includes(searchQuery.toLowerCase())
|
||
);
|
||
|
||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||
const valA = a[sortField].toLowerCase();
|
||
const valB = b[sortField].toLowerCase();
|
||
|
||
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
|
||
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
// Pagination
|
||
const totalPages = Math.ceil(sortedSongs.length / itemsPerPage);
|
||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
const paginatedSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage);
|
||
|
||
// Reset to page 1 when search changes
|
||
useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [searchQuery]);
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<div className="container" style={{ justifyContent: 'center' }}>
|
||
<h1 className="title" style={{ marginBottom: '1rem', fontSize: '1.5rem' }}>Admin Login</h1>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={e => setPassword(e.target.value)}
|
||
className="form-input"
|
||
style={{ marginBottom: '1rem', maxWidth: '300px' }}
|
||
placeholder="Password"
|
||
/>
|
||
<button onClick={handleLogin} className="btn-primary">Login</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="admin-container">
|
||
<h1 className="title" style={{ marginBottom: '2rem' }}>Admin Dashboard</h1>
|
||
|
||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload New Song</h2>
|
||
<form onSubmit={handleUpload}>
|
||
<div className="form-group">
|
||
<label className="form-label">MP3 File (Title and Artist will be extracted from ID3 tags)</label>
|
||
<input
|
||
type="file"
|
||
accept="audio/mpeg"
|
||
onChange={e => setFile(e.target.files?.[0] || null)}
|
||
className="form-input"
|
||
required
|
||
/>
|
||
</div>
|
||
<button type="submit" className="btn-primary">
|
||
Upload Song
|
||
</button>
|
||
{message && (
|
||
<div style={{
|
||
marginTop: '1rem',
|
||
padding: '1rem',
|
||
background: message.includes('⚠️') ? '#fff3cd' : '#d4edda',
|
||
border: `1px solid ${message.includes('⚠️') ? '#ffc107' : '#28a745'}`,
|
||
borderRadius: '0.25rem',
|
||
whiteSpace: 'pre-line',
|
||
fontSize: '0.875rem',
|
||
fontFamily: 'monospace'
|
||
}}>
|
||
{message}
|
||
</div>
|
||
)}
|
||
</form>
|
||
</div>
|
||
|
||
<div className="admin-card">
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||
Song Library ({songs.length} songs)
|
||
</h2>
|
||
|
||
{/* Search */}
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Search by title or artist..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
className="form-input"
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
||
<th style={{ padding: '0.75rem' }}>ID</th>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('title')}
|
||
>
|
||
Title {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('artist')}
|
||
>
|
||
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('createdAt')}
|
||
>
|
||
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th style={{ padding: '0.75rem' }}>Activations</th>
|
||
<th style={{ padding: '0.75rem' }}>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginatedSongs.map(song => (
|
||
<tr key={song.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||
<td style={{ padding: '0.75rem' }}>{song.id}</td>
|
||
|
||
{editingId === song.id ? (
|
||
<>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<input
|
||
type="text"
|
||
value={editTitle}
|
||
onChange={e => setEditTitle(e.target.value)}
|
||
className="form-input"
|
||
style={{ padding: '0.25rem' }}
|
||
/>
|
||
</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<input
|
||
type="text"
|
||
value={editArtist}
|
||
onChange={e => setEditArtist(e.target.value)}
|
||
className="form-input"
|
||
style={{ padding: '0.25rem' }}
|
||
/>
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<button
|
||
onClick={() => saveEditing(song.id)}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title="Save"
|
||
>
|
||
✅
|
||
</button>
|
||
<button
|
||
onClick={cancelEditing}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title="Cancel"
|
||
>
|
||
❌
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</>
|
||
) : (
|
||
<>
|
||
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
|
||
<td style={{ padding: '0.75rem' }}>{song.artist}</td>
|
||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||
</td>
|
||
<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' }}
|
||
title="Edit"
|
||
>
|
||
✏️
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(song.id, song.title)}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title="Delete"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</>
|
||
)}
|
||
</tr>
|
||
))}
|
||
{paginatedSongs.length === 0 && (
|
||
<tr>
|
||
<td colSpan={6} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
||
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', alignItems: 'center' }}>
|
||
<button
|
||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||
disabled={currentPage === 1}
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
border: '1px solid #d1d5db',
|
||
background: currentPage === 1 ? '#f3f4f6' : '#fff',
|
||
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
||
borderRadius: '0.25rem'
|
||
}}
|
||
>
|
||
Previous
|
||
</button>
|
||
<span style={{ color: '#666' }}>
|
||
Page {currentPage} of {totalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||
disabled={currentPage === totalPages}
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
border: '1px solid #d1d5db',
|
||
background: currentPage === totalPages ? '#f3f4f6' : '#fff',
|
||
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
|
||
borderRadius: '0.25rem'
|
||
}}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|