Files
hoerdle/app/admin/page.tsx
Hördle Bot 01bcf179f9 feat: Improve admin dashboard and add weighted song selection
- Replace filename column with activation count
- Add pagination (10 items per page) and search functionality
- Add delete functionality (removes DB entry and file)
- Remove manual title/artist input (auto-extract from ID3 tags)
- Replace text buttons with emoji icons (Edit, Delete, Save, Cancel)
- Implement weighted random selection for daily puzzles
- Add custom favicon
- Fix docker-compose.yml configuration
2025-11-21 13:56:55 +01:00

357 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
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;
const handleLogin = async () => {
const res = await fetch('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ password }),
});
if (res.ok) {
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) {
setMessage('Song uploaded successfully!');
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');
}
};
// 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 && <p style={{ textAlign: 'center', marginTop: '0.5rem' }}>{message}</p>}
</form>
</div>
<div className="admin-card">
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Song Library</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' }}>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' }}>{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' }}>{song.activations}</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<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={5} 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>
);
}