Implement Specials feature, Admin UI enhancements, and Database Rebuild tool
This commit is contained in:
@@ -2,6 +2,17 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
|
||||
interface Special {
|
||||
id: number;
|
||||
name: string;
|
||||
maxAttempts: number;
|
||||
unlockSteps: string;
|
||||
_count?: {
|
||||
songs: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -18,6 +29,7 @@ interface Song {
|
||||
createdAt: string;
|
||||
activations: number;
|
||||
genres: Genre[];
|
||||
specials: Special[];
|
||||
}
|
||||
|
||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
|
||||
@@ -36,11 +48,22 @@ export default function AdminPage() {
|
||||
const [genres, setGenres] = useState<Genre[]>([]);
|
||||
const [newGenreName, setNewGenreName] = useState('');
|
||||
|
||||
// Specials state
|
||||
const [specials, setSpecials] = useState<Special[]>([]);
|
||||
const [newSpecialName, setNewSpecialName] = useState('');
|
||||
const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7);
|
||||
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
|
||||
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
|
||||
const [editSpecialName, setEditSpecialName] = useState('');
|
||||
const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7);
|
||||
const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
|
||||
|
||||
// Edit state
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editArtist, setEditArtist] = useState('');
|
||||
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
||||
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
|
||||
|
||||
// Post-upload state
|
||||
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
||||
@@ -56,7 +79,8 @@ export default function AdminPage() {
|
||||
|
||||
// Search and pagination state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedGenreFilter, setSelectedGenreFilter] = useState<number | null>(null);
|
||||
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
|
||||
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
@@ -119,6 +143,79 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Specials functions
|
||||
const fetchSpecials = async () => {
|
||||
const res = await fetch('/api/specials');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSpecials(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSpecial = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const res = await fetch('/api/specials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newSpecialName,
|
||||
maxAttempts: newSpecialMaxAttempts,
|
||||
unlockSteps: newSpecialUnlockSteps,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewSpecialName('');
|
||||
setNewSpecialMaxAttempts(7);
|
||||
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
|
||||
fetchSpecials();
|
||||
} else {
|
||||
alert('Failed to create special');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSpecial = async (id: number) => {
|
||||
if (!confirm('Delete this special?')) return;
|
||||
const res = await fetch('/api/specials', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (res.ok) fetchSpecials();
|
||||
else alert('Failed to delete special');
|
||||
};
|
||||
|
||||
const startEditSpecial = (special: Special) => {
|
||||
setEditingSpecialId(special.id);
|
||||
setEditSpecialName(special.name);
|
||||
setEditSpecialMaxAttempts(special.maxAttempts);
|
||||
setEditSpecialUnlockSteps(special.unlockSteps);
|
||||
};
|
||||
|
||||
const saveEditedSpecial = async () => {
|
||||
if (editingSpecialId === null) return;
|
||||
const res = await fetch('/api/specials', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingSpecialId,
|
||||
name: editSpecialName,
|
||||
maxAttempts: editSpecialMaxAttempts,
|
||||
unlockSteps: editSpecialUnlockSteps,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEditingSpecialId(null);
|
||||
fetchSpecials();
|
||||
} else {
|
||||
alert('Failed to update special');
|
||||
}
|
||||
};
|
||||
|
||||
// Load specials after auth
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) fetchSpecials();
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const deleteGenre = async (id: number) => {
|
||||
if (!confirm('Delete this genre?')) return;
|
||||
const res = await fetch('/api/genres', {
|
||||
@@ -322,6 +419,7 @@ export default function AdminPage() {
|
||||
setEditTitle(song.title);
|
||||
setEditArtist(song.artist);
|
||||
setEditGenreIds(song.genres.map(g => g.id));
|
||||
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
@@ -329,6 +427,7 @@ export default function AdminPage() {
|
||||
setEditTitle('');
|
||||
setEditArtist('');
|
||||
setEditGenreIds([]);
|
||||
setEditSpecialIds([]);
|
||||
};
|
||||
|
||||
const saveEditing = async (id: number) => {
|
||||
@@ -339,7 +438,8 @@ export default function AdminPage() {
|
||||
id,
|
||||
title: editTitle,
|
||||
artist: editArtist,
|
||||
genreIds: editGenreIds
|
||||
genreIds: editGenreIds,
|
||||
specialIds: editSpecialIds
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -424,10 +524,21 @@ export default function AdminPage() {
|
||||
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
// Genre filter
|
||||
const matchesGenre = selectedGenreFilter === null ||
|
||||
song.genres.some(g => g.id === selectedGenreFilter);
|
||||
// Unified Filter
|
||||
let matchesFilter = true;
|
||||
if (selectedGenreFilter) {
|
||||
if (selectedGenreFilter.startsWith('genre:')) {
|
||||
const genreId = Number(selectedGenreFilter.split(':')[1]);
|
||||
matchesFilter = genreId === -1
|
||||
? song.genres.length === 0
|
||||
: song.genres.some(g => g.id === genreId);
|
||||
} else if (selectedGenreFilter.startsWith('special:')) {
|
||||
const specialId = Number(selectedGenreFilter.split(':')[1]);
|
||||
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
|
||||
}
|
||||
}
|
||||
|
||||
return matchesSearch && matchesGenre;
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||||
@@ -476,6 +587,48 @@ export default function AdminPage() {
|
||||
<div className="admin-container">
|
||||
<h1 className="title" style={{ marginBottom: '2rem' }}>Hördle Admin Dashboard</h1>
|
||||
|
||||
{/* Special Management */}
|
||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Specials</h2>
|
||||
<form onSubmit={handleCreateSpecial} style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<input type="text" placeholder="Special name" value={newSpecialName} onChange={e => setNewSpecialName(e.target.value)} className="form-input" required />
|
||||
<input type="number" placeholder="Max attempts" value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} />
|
||||
<input type="text" placeholder="Unlock steps JSON" value={newSpecialUnlockSteps} onChange={e => setNewSpecialUnlockSteps(e.target.value)} className="form-input" />
|
||||
<button type="submit" className="btn-primary">Add Special</button>
|
||||
</div>
|
||||
</form>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{specials.map(special => (
|
||||
<div key={special.id} style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
<span>{special.name} ({special._count?.songs || 0})</span>
|
||||
<button onClick={() => startEditSpecial(special)} className="btn-primary" style={{ marginRight: '0.5rem' }}>Edit</button>
|
||||
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{editingSpecialId !== null && (
|
||||
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
|
||||
<h3>Edit Special</h3>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<input type="text" value={editSpecialName} onChange={e => setEditSpecialName(e.target.value)} className="form-input" />
|
||||
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} />
|
||||
<input type="text" value={editSpecialUnlockSteps} onChange={e => setEditSpecialUnlockSteps(e.target.value)} className="form-input" />
|
||||
<button onClick={saveEditedSpecial} className="btn-primary">Save</button>
|
||||
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Genre Management */}
|
||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
|
||||
@@ -704,23 +857,33 @@ export default function AdminPage() {
|
||||
style={{ flex: '1', minWidth: '200px' }}
|
||||
/>
|
||||
<select
|
||||
value={selectedGenreFilter || ''}
|
||||
onChange={e => setSelectedGenreFilter(e.target.value ? Number(e.target.value) : null)}
|
||||
value={selectedGenreFilter}
|
||||
onChange={e => setSelectedGenreFilter(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ minWidth: '150px' }}
|
||||
>
|
||||
<option value="">All Genres</option>
|
||||
{genres.map(genre => (
|
||||
<option key={genre.id} value={genre.id}>
|
||||
{genre.name} ({genre._count?.songs || 0})
|
||||
</option>
|
||||
))}
|
||||
<option value="">All Content</option>
|
||||
<optgroup label="Genres">
|
||||
<option value="genre:-1">No Genre</option>
|
||||
{genres.map(genre => (
|
||||
<option key={genre.id} value={`genre:${genre.id}`}>
|
||||
{genre.name} ({genre._count?.songs || 0})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Specials">
|
||||
{specials.map(special => (
|
||||
<option key={special.id} value={`special:${special.id}`}>
|
||||
★ {special.name} ({special._count?.songs || 0})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
{(searchQuery || selectedGenreFilter) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedGenreFilter(null);
|
||||
setSelectedGenreFilter('');
|
||||
}}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
@@ -813,6 +976,24 @@ export default function AdminPage() {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.25rem' }}>
|
||||
{specials.map(special => (
|
||||
<label key={special.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#4b5563' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editSpecialIds.includes(special.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setEditSpecialIds([...editSpecialIds, special.id]);
|
||||
} else {
|
||||
setEditSpecialIds(editSpecialIds.filter(id => id !== special.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{special.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||||
@@ -854,6 +1035,19 @@ export default function AdminPage() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
||||
{song.specials?.map(s => (
|
||||
<span key={s.id} style={{
|
||||
background: '#fce7f3',
|
||||
color: '#9d174d',
|
||||
padding: '0.1rem 0.4rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.7rem'
|
||||
}}>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||||
@@ -934,6 +1128,47 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>
|
||||
Danger Zone
|
||||
</h2>
|
||||
<p style={{ marginBottom: '1rem', color: '#666' }}>
|
||||
These actions are destructive and cannot be undone.
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (window.confirm('⚠️ WARNING: This will delete ALL data from the database (Songs, Genres, Specials, Puzzles) and re-import songs from the uploads folder.\n\nExisting genres and specials will be LOST and must be recreated manually.\n\nAre you sure you want to proceed?')) {
|
||||
try {
|
||||
setMessage('Rebuilding database... this may take a while.');
|
||||
const res = await fetch('/api/admin/rebuild', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
alert(data.message + '\n\nPlease recreate your Genres and Specials now.');
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Rebuild failed. Check server logs.');
|
||||
setMessage('Rebuild failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Rebuild failed due to network error.');
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
☢️ Rebuild Database
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user