1611 lines
79 KiB
TypeScript
1611 lines
79 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
|
||
|
||
interface Special {
|
||
id: number;
|
||
name: string;
|
||
subtitle?: string;
|
||
maxAttempts: number;
|
||
unlockSteps: string;
|
||
launchDate?: string;
|
||
endDate?: string;
|
||
curator?: string;
|
||
_count?: {
|
||
songs: number;
|
||
};
|
||
}
|
||
|
||
interface Genre {
|
||
id: number;
|
||
name: string;
|
||
subtitle?: string;
|
||
_count?: {
|
||
songs: number;
|
||
};
|
||
}
|
||
|
||
interface DailyPuzzle {
|
||
id: number;
|
||
date: string;
|
||
songId: number;
|
||
genreId: number | null;
|
||
specialId: number | null;
|
||
}
|
||
|
||
interface Song {
|
||
id: number;
|
||
title: string;
|
||
artist: string;
|
||
filename: string;
|
||
createdAt: string;
|
||
releaseYear: number | null;
|
||
activations: number;
|
||
puzzles: DailyPuzzle[];
|
||
genres: Genre[];
|
||
specials: Special[];
|
||
averageRating: number;
|
||
ratingCount: number;
|
||
}
|
||
|
||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
|
||
type SortDirection = 'asc' | 'desc';
|
||
|
||
export default function AdminPage() {
|
||
const [password, setPassword] = useState('');
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||
const [files, setFiles] = useState<File[]>([]);
|
||
const [message, setMessage] = useState('');
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [uploadProgress, setUploadProgress] = useState<{ current: number, total: number }>({ current: 0, total: 0 });
|
||
const [uploadResults, setUploadResults] = useState<any[]>([]);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [songs, setSongs] = useState<Song[]>([]);
|
||
const [genres, setGenres] = useState<Genre[]>([]);
|
||
const [newGenreName, setNewGenreName] = useState('');
|
||
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
|
||
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
|
||
const [editGenreName, setEditGenreName] = useState('');
|
||
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
|
||
|
||
// Specials state
|
||
const [specials, setSpecials] = useState<Special[]>([]);
|
||
const [newSpecialName, setNewSpecialName] = useState('');
|
||
const [newSpecialSubtitle, setNewSpecialSubtitle] = useState('');
|
||
const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7);
|
||
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
|
||
const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState('');
|
||
const [newSpecialEndDate, setNewSpecialEndDate] = useState('');
|
||
const [newSpecialCurator, setNewSpecialCurator] = useState('');
|
||
|
||
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
|
||
const [editSpecialName, setEditSpecialName] = useState('');
|
||
const [editSpecialSubtitle, setEditSpecialSubtitle] = useState('');
|
||
const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7);
|
||
const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
|
||
const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState('');
|
||
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
|
||
const [editSpecialCurator, setEditSpecialCurator] = useState('');
|
||
|
||
// Edit state
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
const [editTitle, setEditTitle] = useState('');
|
||
const [editArtist, setEditArtist] = useState('');
|
||
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
|
||
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
||
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
|
||
|
||
// Post-upload state
|
||
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||
|
||
// AI Categorization state
|
||
const [isCategorizing, setIsCategorizing] = useState(false);
|
||
const [categorizationResults, setCategorizationResults] = useState<any>(null);
|
||
|
||
// Sort state
|
||
const [sortField, setSortField] = useState<SortField>('artist');
|
||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||
|
||
// Search and pagination state
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
|
||
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const itemsPerPage = 10;
|
||
|
||
// Audio state
|
||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||
|
||
// Daily Puzzles state
|
||
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
|
||
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
|
||
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
|
||
|
||
// Check for existing auth on mount
|
||
useEffect(() => {
|
||
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
||
if (authToken === 'authenticated') {
|
||
setIsAuthenticated(true);
|
||
fetchSongs();
|
||
fetchGenres();
|
||
fetchDailyPuzzles();
|
||
}
|
||
}, []);
|
||
|
||
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();
|
||
fetchGenres();
|
||
fetchDailyPuzzles();
|
||
} else {
|
||
alert('Wrong password');
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
localStorage.removeItem('hoerdle_admin_auth');
|
||
setIsAuthenticated(false);
|
||
setPassword('');
|
||
// Reset all state
|
||
setSongs([]);
|
||
setGenres([]);
|
||
setSpecials([]);
|
||
setDailyPuzzles([]);
|
||
};
|
||
|
||
// Helper function to add auth headers to requests
|
||
const getAuthHeaders = () => {
|
||
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
||
return {
|
||
'Content-Type': 'application/json',
|
||
'x-admin-auth': authToken || ''
|
||
};
|
||
};
|
||
|
||
const fetchSongs = async () => {
|
||
const res = await fetch('/api/songs', {
|
||
headers: getAuthHeaders()
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setSongs(data);
|
||
}
|
||
};
|
||
|
||
const fetchGenres = async () => {
|
||
const res = await fetch('/api/genres', {
|
||
headers: getAuthHeaders()
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setGenres(data);
|
||
}
|
||
};
|
||
|
||
const createGenre = async () => {
|
||
if (!newGenreName.trim()) return;
|
||
const res = await fetch('/api/genres', {
|
||
method: 'POST',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
|
||
});
|
||
if (res.ok) {
|
||
setNewGenreName('');
|
||
setNewGenreSubtitle('');
|
||
fetchGenres();
|
||
} else {
|
||
alert('Failed to create genre');
|
||
}
|
||
};
|
||
|
||
const startEditGenre = (genre: Genre) => {
|
||
setEditingGenreId(genre.id);
|
||
setEditGenreName(genre.name);
|
||
setEditGenreSubtitle(genre.subtitle || '');
|
||
};
|
||
|
||
const saveEditedGenre = async () => {
|
||
if (editingGenreId === null) return;
|
||
const res = await fetch('/api/genres', {
|
||
method: 'PUT',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
id: editingGenreId,
|
||
name: editGenreName,
|
||
subtitle: editGenreSubtitle
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
setEditingGenreId(null);
|
||
fetchGenres();
|
||
} else {
|
||
alert('Failed to update genre');
|
||
}
|
||
};
|
||
|
||
// Specials functions
|
||
const fetchSpecials = async () => {
|
||
const res = await fetch('/api/specials', {
|
||
headers: getAuthHeaders()
|
||
});
|
||
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: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
name: newSpecialName,
|
||
subtitle: newSpecialSubtitle,
|
||
maxAttempts: newSpecialMaxAttempts,
|
||
unlockSteps: newSpecialUnlockSteps,
|
||
launchDate: newSpecialLaunchDate || null,
|
||
endDate: newSpecialEndDate || null,
|
||
curator: newSpecialCurator || null,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
setNewSpecialName('');
|
||
setNewSpecialSubtitle('');
|
||
setNewSpecialMaxAttempts(7);
|
||
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
|
||
setNewSpecialLaunchDate('');
|
||
setNewSpecialEndDate('');
|
||
setNewSpecialCurator('');
|
||
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: getAuthHeaders(),
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
if (res.ok) fetchSpecials();
|
||
else alert('Failed to delete special');
|
||
};
|
||
|
||
// Daily Puzzles functions
|
||
const fetchDailyPuzzles = async () => {
|
||
const res = await fetch('/api/admin/daily-puzzles', {
|
||
headers: getAuthHeaders()
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setDailyPuzzles(data);
|
||
}
|
||
};
|
||
|
||
const handleDeletePuzzle = async (puzzleId: number) => {
|
||
if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return;
|
||
const res = await fetch('/api/admin/daily-puzzles', {
|
||
method: 'DELETE',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({ puzzleId }),
|
||
});
|
||
if (res.ok) {
|
||
fetchDailyPuzzles();
|
||
alert('Puzzle deleted and regenerated successfully');
|
||
} else {
|
||
alert('Failed to delete puzzle');
|
||
}
|
||
};
|
||
|
||
const handlePlayPuzzle = (puzzle: any) => {
|
||
if (playingPuzzleId === puzzle.id) {
|
||
// Pause
|
||
audioElement?.pause();
|
||
setPlayingPuzzleId(null);
|
||
setAudioElement(null);
|
||
} else {
|
||
// Stop any currently playing audio
|
||
if (audioElement) {
|
||
audioElement.pause();
|
||
setAudioElement(null);
|
||
}
|
||
|
||
const audio = new Audio(puzzle.song.audioUrl);
|
||
audio.play()
|
||
.then(() => {
|
||
setAudioElement(audio);
|
||
setPlayingPuzzleId(puzzle.id);
|
||
})
|
||
.catch((error) => {
|
||
console.error('Playback error:', error);
|
||
alert(`Failed to play audio: ${error.message}`);
|
||
setPlayingPuzzleId(null);
|
||
setAudioElement(null);
|
||
});
|
||
|
||
audio.onended = () => {
|
||
setPlayingPuzzleId(null);
|
||
setAudioElement(null);
|
||
};
|
||
}
|
||
};
|
||
|
||
const startEditSpecial = (special: Special) => {
|
||
setEditingSpecialId(special.id);
|
||
setEditSpecialName(special.name);
|
||
setEditSpecialSubtitle(special.subtitle || '');
|
||
setEditSpecialMaxAttempts(special.maxAttempts);
|
||
setEditSpecialUnlockSteps(special.unlockSteps);
|
||
setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : '');
|
||
setEditSpecialEndDate(special.endDate ? new Date(special.endDate).toISOString().split('T')[0] : '');
|
||
setEditSpecialCurator(special.curator || '');
|
||
};
|
||
|
||
const saveEditedSpecial = async () => {
|
||
if (editingSpecialId === null) return;
|
||
const res = await fetch('/api/specials', {
|
||
method: 'PUT',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
id: editingSpecialId,
|
||
name: editSpecialName,
|
||
subtitle: editSpecialSubtitle,
|
||
maxAttempts: editSpecialMaxAttempts,
|
||
unlockSteps: editSpecialUnlockSteps,
|
||
launchDate: editSpecialLaunchDate || null,
|
||
endDate: editSpecialEndDate || null,
|
||
curator: editSpecialCurator || null,
|
||
}),
|
||
});
|
||
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', {
|
||
method: 'DELETE',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
if (res.ok) {
|
||
fetchGenres();
|
||
} else {
|
||
alert('Failed to delete genre');
|
||
}
|
||
};
|
||
|
||
const handleAICategorization = async () => {
|
||
if (!confirm('This will categorize all songs without genres using AI. Continue?')) return;
|
||
|
||
setIsCategorizing(true);
|
||
setCategorizationResults(null);
|
||
|
||
try {
|
||
let offset = 0;
|
||
let hasMore = true;
|
||
let allResults: any[] = [];
|
||
let totalUncategorized = 0;
|
||
let totalProcessed = 0;
|
||
|
||
// Process in batches
|
||
while (hasMore) {
|
||
const res = await fetch('/api/categorize', {
|
||
method: 'POST',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({ offset })
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const error = await res.json();
|
||
alert(`Categorization failed: ${error.error || 'Unknown error'}`);
|
||
break;
|
||
}
|
||
|
||
const data = await res.json();
|
||
totalUncategorized = data.totalUncategorized;
|
||
totalProcessed = data.processed;
|
||
hasMore = data.hasMore;
|
||
offset = data.nextOffset || 0;
|
||
|
||
// Accumulate results
|
||
allResults = [...allResults, ...data.results];
|
||
|
||
// Update UI with progress
|
||
setCategorizationResults({
|
||
message: `Processing: ${totalProcessed} / ${totalUncategorized} songs...`,
|
||
totalProcessed: totalUncategorized,
|
||
totalCategorized: allResults.length,
|
||
results: allResults,
|
||
inProgress: hasMore
|
||
});
|
||
}
|
||
|
||
// Final update
|
||
setCategorizationResults({
|
||
message: `Completed! Processed ${totalUncategorized} songs, categorized ${allResults.length}`,
|
||
totalProcessed: totalUncategorized,
|
||
totalCategorized: allResults.length,
|
||
results: allResults,
|
||
inProgress: false
|
||
});
|
||
|
||
fetchSongs(); // Refresh song list
|
||
fetchGenres(); // Refresh genre counts
|
||
} catch (error) {
|
||
alert('Failed to categorize songs');
|
||
console.error(error);
|
||
} finally {
|
||
setIsCategorizing(false);
|
||
}
|
||
};
|
||
|
||
const handleBatchUpload = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (files.length === 0) return;
|
||
|
||
setIsUploading(true);
|
||
setUploadResults([]);
|
||
setUploadProgress({ current: 0, total: files.length });
|
||
setMessage('');
|
||
|
||
const results = [];
|
||
|
||
// Upload files sequentially to avoid timeout
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i];
|
||
setUploadProgress({ current: i + 1, total: files.length });
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const res = await fetch('/api/songs', {
|
||
method: 'POST',
|
||
headers: { 'x-admin-auth': localStorage.getItem('hoerdle_admin_auth') || '' },
|
||
body: formData,
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
results.push({
|
||
filename: file.name,
|
||
success: true,
|
||
song: data.song,
|
||
validation: data.validation
|
||
});
|
||
} else if (res.status === 409) {
|
||
// Duplicate detected
|
||
const data = await res.json();
|
||
results.push({
|
||
filename: file.name,
|
||
success: false,
|
||
isDuplicate: true,
|
||
duplicate: data.duplicate,
|
||
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
|
||
});
|
||
} else {
|
||
results.push({
|
||
filename: file.name,
|
||
success: false,
|
||
error: 'Upload failed'
|
||
});
|
||
}
|
||
} catch (error) {
|
||
results.push({
|
||
filename: file.name,
|
||
success: false,
|
||
error: 'Network error'
|
||
});
|
||
}
|
||
}
|
||
|
||
setUploadResults(results);
|
||
setFiles([]);
|
||
setIsUploading(false);
|
||
fetchSongs();
|
||
fetchGenres();
|
||
fetchSpecials(); // Update special counts
|
||
|
||
// Auto-trigger categorization after uploads
|
||
const successCount = results.filter(r => r.success).length;
|
||
const duplicateCount = results.filter(r => r.isDuplicate).length;
|
||
const failedCount = results.filter(r => !r.success && !r.isDuplicate).length;
|
||
if (successCount > 0) {
|
||
let msg = `✅ Uploaded ${successCount}/${files.length} songs successfully!`;
|
||
if (duplicateCount > 0) {
|
||
msg += `\n⚠️ Skipped ${duplicateCount} duplicate(s)`;
|
||
}
|
||
if (failedCount > 0) {
|
||
msg += `\n❌ ${failedCount} failed`;
|
||
}
|
||
msg += '\n\n🤖 Starting auto-categorization...';
|
||
setMessage(msg);
|
||
// Small delay to let user see the message
|
||
setTimeout(() => {
|
||
handleAICategorization();
|
||
}, 1000);
|
||
} else if (duplicateCount > 0 && failedCount === 0) {
|
||
setMessage(`⚠️ All ${duplicateCount} file(s) were duplicates - nothing uploaded.`);
|
||
} else {
|
||
setMessage(`❌ All uploads failed.`);
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(true);
|
||
};
|
||
|
||
const handleDragLeave = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
|
||
const droppedFiles = Array.from(e.dataTransfer.files).filter(
|
||
file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3')
|
||
);
|
||
|
||
if (droppedFiles.length > 0) {
|
||
setFiles(droppedFiles);
|
||
}
|
||
};
|
||
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files) {
|
||
setFiles(Array.from(e.target.files));
|
||
}
|
||
};
|
||
|
||
const saveUploadedSongGenres = async () => {
|
||
if (!uploadedSong) return;
|
||
|
||
const res = await fetch('/api/songs', {
|
||
method: 'PUT',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
id: uploadedSong.id,
|
||
title: uploadedSong.title,
|
||
artist: uploadedSong.artist,
|
||
genreIds: uploadGenreIds
|
||
}),
|
||
});
|
||
|
||
if (res.ok) {
|
||
setUploadedSong(null);
|
||
setUploadGenreIds([]);
|
||
fetchSongs();
|
||
fetchGenres();
|
||
fetchSpecials(); // Update special counts if song was assigned to specials
|
||
setMessage(prev => prev + '\n✅ Genres assigned successfully!');
|
||
} else {
|
||
alert('Failed to assign genres');
|
||
}
|
||
};
|
||
|
||
const startEditing = (song: Song) => {
|
||
setEditingId(song.id);
|
||
setEditTitle(song.title);
|
||
setEditArtist(song.artist);
|
||
setEditReleaseYear(song.releaseYear || '');
|
||
setEditGenreIds(song.genres.map(g => g.id));
|
||
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
|
||
};
|
||
|
||
const cancelEditing = () => {
|
||
setEditingId(null);
|
||
setEditTitle('');
|
||
setEditArtist('');
|
||
setEditReleaseYear('');
|
||
setEditGenreIds([]);
|
||
setEditSpecialIds([]);
|
||
};
|
||
|
||
const saveEditing = async (id: number) => {
|
||
const res = await fetch('/api/songs', {
|
||
method: 'PUT',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
id,
|
||
title: editTitle,
|
||
artist: editArtist,
|
||
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
|
||
genreIds: editGenreIds,
|
||
specialIds: editSpecialIds
|
||
}),
|
||
});
|
||
|
||
if (res.ok) {
|
||
setEditingId(null);
|
||
fetchSongs();
|
||
fetchGenres();
|
||
fetchSpecials(); // Update special counts
|
||
} 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: getAuthHeaders(),
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
|
||
if (res.ok) {
|
||
fetchSongs();
|
||
fetchGenres();
|
||
fetchSpecials(); // Update special counts
|
||
} 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(`/api/audio/${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 => {
|
||
// Text search filter
|
||
const matchesSearch = song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
|
||
|
||
// Genre filter
|
||
// 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;
|
||
} else if (selectedGenreFilter === 'daily') {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
|
||
}
|
||
}
|
||
|
||
return matchesSearch && matchesFilter;
|
||
});
|
||
|
||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||
// Handle numeric sorting for ID and Release Year
|
||
if (sortField === 'id') {
|
||
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
||
}
|
||
if (sortField === 'releaseYear') {
|
||
const yearA = a.releaseYear || 0;
|
||
const yearB = b.releaseYear || 0;
|
||
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
|
||
}
|
||
|
||
// String sorting for other fields
|
||
const valA = String(a[sortField]).toLowerCase();
|
||
const valB = String(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">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||
<h1 className="title" style={{ margin: 0 }}>Hördle Admin Dashboard</h1>
|
||
<button
|
||
onClick={handleLogout}
|
||
className="btn-secondary"
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
backgroundColor: '#dc3545',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
fontSize: '0.9rem'
|
||
}}
|
||
>
|
||
🚪 Logout
|
||
</button>
|
||
</div>
|
||
|
||
{/* 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', alignItems: 'flex-end' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
|
||
<input type="text" placeholder="Special name" value={newSpecialName} onChange={e => setNewSpecialName(e.target.value)} className="form-input" required />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
||
<input type="text" placeholder="Subtitle" value={newSpecialSubtitle} onChange={e => setNewSpecialSubtitle(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label>
|
||
<input type="number" placeholder="Max attempts" value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Unlock Steps</label>
|
||
<input type="text" placeholder="Unlock steps JSON" value={newSpecialUnlockSteps} onChange={e => setNewSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Launch Date</label>
|
||
<input type="date" value={newSpecialLaunchDate} onChange={e => setNewSpecialLaunchDate(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</label>
|
||
<input type="date" value={newSpecialEndDate} onChange={e => setNewSpecialEndDate(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Curator</label>
|
||
<input type="text" placeholder="Curator name" value={newSpecialCurator} onChange={e => setNewSpecialCurator(e.target.value)} className="form-input" />
|
||
</div>
|
||
<button type="submit" className="btn-primary" style={{ height: '38px' }}>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>
|
||
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {special.subtitle}</span>}
|
||
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a>
|
||
<button onClick={() => startEditSpecial(special)} className="btn-secondary" 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', alignItems: 'flex-end' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
|
||
<input type="text" value={editSpecialName} onChange={e => setEditSpecialName(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
||
<input type="text" value={editSpecialSubtitle} onChange={e => setEditSpecialSubtitle(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label>
|
||
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Unlock Steps</label>
|
||
<input type="text" value={editSpecialUnlockSteps} onChange={e => setEditSpecialUnlockSteps(e.target.value)} className="form-input" style={{ width: '200px' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Launch Date</label>
|
||
<input type="date" value={editSpecialLaunchDate} onChange={e => setEditSpecialLaunchDate(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>End Date</label>
|
||
<input type="date" value={editSpecialEndDate} onChange={e => setEditSpecialEndDate(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Curator</label>
|
||
<input type="text" value={editSpecialCurator} onChange={e => setEditSpecialCurator(e.target.value)} className="form-input" />
|
||
</div>
|
||
<button onClick={saveEditedSpecial} className="btn-primary" style={{ height: '38px' }}>Save</button>
|
||
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary" style={{ height: '38px' }}>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>
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||
<input
|
||
type="text"
|
||
value={newGenreName}
|
||
onChange={e => setNewGenreName(e.target.value)}
|
||
placeholder="New Genre Name"
|
||
className="form-input"
|
||
style={{ maxWidth: '200px' }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={newGenreSubtitle}
|
||
onChange={e => setNewGenreSubtitle(e.target.value)}
|
||
placeholder="Subtitle"
|
||
className="form-input"
|
||
style={{ maxWidth: '300px' }}
|
||
/>
|
||
<button onClick={createGenre} className="btn-primary">Add Genre</button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||
{genres.map(genre => (
|
||
<div key={genre.id} style={{
|
||
background: '#f3f4f6',
|
||
padding: '0.25rem 0.75rem',
|
||
borderRadius: '999px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.5rem',
|
||
fontSize: '0.875rem'
|
||
}}>
|
||
<span>{genre.name} ({genre._count?.songs || 0})</span>
|
||
{genre.subtitle && <span style={{ fontSize: '0.75rem', color: '#666' }}>- {genre.subtitle}</span>}
|
||
<button onClick={() => startEditGenre(genre)} className="btn-secondary" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>Edit</button>
|
||
<button onClick={() => deleteGenre(genre.id)} className="btn-danger" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{editingGenreId !== null && (
|
||
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
|
||
<h3>Edit Genre</h3>
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
|
||
<input type="text" value={editGenreName} onChange={e => setEditGenreName(e.target.value)} className="form-input" />
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
||
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
|
||
</div>
|
||
<button onClick={saveEditedGenre} className="btn-primary">Save</button>
|
||
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* AI Categorization */}
|
||
<div style={{ marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid #e5e7eb' }}>
|
||
<button
|
||
onClick={handleAICategorization}
|
||
disabled={isCategorizing || genres.length === 0}
|
||
className="btn-primary"
|
||
style={{
|
||
opacity: isCategorizing || genres.length === 0 ? 0.5 : 1,
|
||
cursor: isCategorizing || genres.length === 0 ? 'not-allowed' : 'pointer'
|
||
}}
|
||
>
|
||
{isCategorizing ? '🤖 Categorizing...' : '🤖 Auto-Categorize Songs with AI'}
|
||
</button>
|
||
{genres.length === 0 && (
|
||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||
Please create at least one genre first.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Categorization Results */}
|
||
{categorizationResults && (
|
||
<div style={{
|
||
marginTop: '1rem',
|
||
padding: '1rem',
|
||
background: '#f0fdf4',
|
||
border: '1px solid #86efac',
|
||
borderRadius: '0.5rem'
|
||
}}>
|
||
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem', color: '#166534' }}>
|
||
✅ Categorization Complete
|
||
</h3>
|
||
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||
{categorizationResults.message}
|
||
</p>
|
||
{categorizationResults.results && categorizationResults.results.length > 0 && (
|
||
<div style={{ marginTop: '1rem' }}>
|
||
<p style={{ fontWeight: 'bold', fontSize: '0.875rem', marginBottom: '0.5rem' }}>Updated Songs:</p>
|
||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||
{categorizationResults.results.map((result: any) => (
|
||
<div key={result.songId} style={{
|
||
padding: '0.5rem',
|
||
background: 'white',
|
||
borderRadius: '0.25rem',
|
||
marginBottom: '0.5rem',
|
||
fontSize: '0.875rem'
|
||
}}>
|
||
<strong>{result.title}</strong> by {result.artist}
|
||
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem', flexWrap: 'wrap' }}>
|
||
{result.assignedGenres.map((genre: string) => (
|
||
<span key={genre} style={{
|
||
background: '#dbeafe',
|
||
padding: '0.1rem 0.4rem',
|
||
borderRadius: '0.25rem',
|
||
fontSize: '0.75rem'
|
||
}}>
|
||
{genre}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={() => setCategorizationResults(null)}
|
||
style={{
|
||
marginTop: '1rem',
|
||
padding: '0.5rem 1rem',
|
||
background: 'white',
|
||
border: '1px solid #d1d5db',
|
||
borderRadius: '0.25rem',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload Songs</h2>
|
||
<form onSubmit={handleBatchUpload}>
|
||
{/* Drag & Drop Zone */}
|
||
<div
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
style={{
|
||
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
|
||
borderRadius: '0.5rem',
|
||
padding: '2rem',
|
||
textAlign: 'center',
|
||
background: isDragging ? '#eef2ff' : '#f9fafb',
|
||
marginBottom: '1rem',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.2s'
|
||
}}
|
||
onClick={() => document.getElementById('file-input')?.click()}
|
||
>
|
||
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
|
||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
||
{files.length > 0 ? `${files.length} file(s) selected` : 'Drag & drop MP3 files here'}
|
||
</p>
|
||
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
||
or click to browse
|
||
</p>
|
||
<input
|
||
id="file-input"
|
||
type="file"
|
||
accept="audio/mpeg"
|
||
multiple
|
||
onChange={handleFileChange}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* File List */}
|
||
{files.length > 0 && (
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Selected Files:</p>
|
||
<div style={{ maxHeight: '200px', overflowY: 'auto', background: '#f9fafb', padding: '0.5rem', borderRadius: '0.25rem' }}>
|
||
{files.map((file, index) => (
|
||
<div key={index} style={{ padding: '0.25rem 0', fontSize: '0.875rem' }}>
|
||
📄 {file.name}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Upload Progress */}
|
||
{isUploading && (
|
||
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem' }}>
|
||
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||
Uploading: {uploadProgress.current} / {uploadProgress.total}
|
||
</p>
|
||
<div style={{ width: '100%', height: '8px', background: '#d1d5db', borderRadius: '4px', overflow: 'hidden' }}>
|
||
<div style={{
|
||
width: `${(uploadProgress.current / uploadProgress.total) * 100}%`,
|
||
height: '100%',
|
||
background: '#4f46e5',
|
||
transition: 'width 0.3s'
|
||
}} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
className="btn-primary"
|
||
disabled={files.length === 0 || isUploading}
|
||
style={{ opacity: files.length === 0 || isUploading ? 0.5 : 1 }}
|
||
>
|
||
{isUploading ? 'Uploading...' : `Upload ${files.length} Song(s)`}
|
||
</button>
|
||
|
||
{message && (
|
||
<div style={{
|
||
marginTop: '1rem',
|
||
padding: '0.75rem',
|
||
background: '#d1fae5',
|
||
color: '#065f46',
|
||
borderRadius: '0.25rem'
|
||
}}>
|
||
{message}
|
||
</div>
|
||
)}
|
||
</form>
|
||
</div>
|
||
|
||
{/* Today's Daily Puzzles */}
|
||
<div className="admin-card">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
|
||
Today's Daily Puzzles
|
||
</h2>
|
||
<button
|
||
onClick={() => setShowDailyPuzzles(!showDailyPuzzles)}
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
background: '#f3f4f6',
|
||
border: '1px solid #d1d5db',
|
||
borderRadius: '0.25rem',
|
||
cursor: 'pointer',
|
||
fontSize: '0.875rem'
|
||
}}
|
||
>
|
||
{showDailyPuzzles ? '▼ Hide' : '▶ Show'}
|
||
</button>
|
||
</div>
|
||
{showDailyPuzzles && (dailyPuzzles.length === 0 ? (
|
||
<p style={{ color: '#6b7280' }}>No daily puzzles found for today.</p>
|
||
) : (
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid #e5e7eb' }}>
|
||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Category</th>
|
||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Song</th>
|
||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Artist</th>
|
||
<th style={{ padding: '0.75rem', textAlign: 'center', fontWeight: 'bold' }}>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{dailyPuzzles.map(puzzle => (
|
||
<tr key={puzzle.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<span style={{
|
||
background: puzzle.categoryType === 'global' ? '#dbeafe' : puzzle.categoryType === 'special' ? '#fce7f3' : '#f3f4f6',
|
||
color: puzzle.categoryType === 'global' ? '#1e40af' : puzzle.categoryType === 'special' ? '#be185d' : '#374151',
|
||
padding: '0.25rem 0.5rem',
|
||
borderRadius: '0.25rem',
|
||
fontSize: '0.875rem',
|
||
fontWeight: '500'
|
||
}}>
|
||
{puzzle.category}
|
||
</span>
|
||
</td>
|
||
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{puzzle.song.title}</td>
|
||
<td style={{ padding: '0.75rem' }}>{puzzle.song.artist}</td>
|
||
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
|
||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
|
||
<button
|
||
onClick={() => handlePlayPuzzle(puzzle)}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title={playingPuzzleId === puzzle.id ? "Pause" : "Play"}
|
||
>
|
||
{playingPuzzleId === puzzle.id ? '⏸️' : '▶️'}
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeletePuzzle(puzzle.id)}
|
||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
||
title="Delete"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
))}
|
||
</div>
|
||
|
||
<div className="admin-card">
|
||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||
Song Library ({songs.length} songs)
|
||
</h2>
|
||
|
||
{/* Search and Filter */}
|
||
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Search by title or artist..."
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
className="form-input"
|
||
style={{ flex: '1', minWidth: '200px' }}
|
||
/>
|
||
<select
|
||
value={selectedGenreFilter}
|
||
onChange={e => setSelectedGenreFilter(e.target.value)}
|
||
className="form-input"
|
||
style={{ minWidth: '150px' }}
|
||
>
|
||
<option value="">All Content</option>
|
||
<option value="daily">📅 Song of the Day</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('');
|
||
}}
|
||
style={{
|
||
padding: '0.5rem 1rem',
|
||
background: '#f3f4f6',
|
||
border: '1px solid #d1d5db',
|
||
borderRadius: '0.25rem',
|
||
cursor: 'pointer',
|
||
fontSize: '0.875rem'
|
||
}}
|
||
>
|
||
Clear Filters
|
||
</button>
|
||
)}
|
||
</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', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('id')}
|
||
>
|
||
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('title')}
|
||
>
|
||
Song {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th
|
||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => handleSort('releaseYear')}
|
||
>
|
||
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||
</th>
|
||
<th style={{ padding: '0.75rem' }}>Genres / Specials</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' }}>Rating</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', marginBottom: '0.5rem', width: '100%' }}
|
||
placeholder="Title"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={editArtist}
|
||
onChange={e => setEditArtist(e.target.value)}
|
||
className="form-input"
|
||
style={{ padding: '0.25rem', width: '100%' }}
|
||
placeholder="Artist"
|
||
/>
|
||
</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<input
|
||
type="number"
|
||
value={editReleaseYear}
|
||
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
|
||
className="form-input"
|
||
style={{ padding: '0.25rem', width: '80px' }}
|
||
placeholder="Year"
|
||
/>
|
||
</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||
{genres.map(genre => (
|
||
<label key={genre.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={editGenreIds.includes(genre.id)}
|
||
onChange={e => {
|
||
if (e.target.checked) {
|
||
setEditGenreIds([...editGenreIds, genre.id]);
|
||
} else {
|
||
setEditGenreIds(editGenreIds.filter(id => id !== genre.id));
|
||
}
|
||
}}
|
||
/>
|
||
{genre.name}
|
||
</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')}
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
||
{song.averageRating > 0 ? (
|
||
<span title={`${song.ratingCount} ratings`}>
|
||
{song.averageRating.toFixed(1)} ★ <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
|
||
</span>
|
||
) : (
|
||
<span style={{ color: '#ccc' }}>-</span>
|
||
)}
|
||
</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' }}>
|
||
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
|
||
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
|
||
|
||
{/* Daily Puzzle Badges */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
||
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
|
||
if (!p.genreId && !p.specialId) {
|
||
return (
|
||
<span key={p.id} style={{ background: '#dbeafe', color: '#1e40af', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #93c5fd' }}>
|
||
🌍 Global Daily
|
||
</span>
|
||
);
|
||
}
|
||
if (p.genreId) {
|
||
const genreName = genres.find(g => g.id === p.genreId)?.name;
|
||
return (
|
||
<span key={p.id} style={{ background: '#f3f4f6', color: '#374151', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #d1d5db' }}>
|
||
🏷️ {genreName} Daily
|
||
</span>
|
||
);
|
||
}
|
||
if (p.specialId) {
|
||
const specialName = specials.find(s => s.id === p.specialId)?.name;
|
||
return (
|
||
<span key={p.id} style={{ background: '#fce7f3', color: '#be185d', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #fbcfe8' }}>
|
||
★ {specialName} Daily
|
||
</span>
|
||
);
|
||
}
|
||
return null;
|
||
})}
|
||
</div>
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
||
{song.releaseYear || '-'}
|
||
</td>
|
||
<td style={{ padding: '0.75rem' }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||
{song.genres?.map(g => (
|
||
<span key={g.id} style={{
|
||
background: '#e5e7eb',
|
||
padding: '0.1rem 0.4rem',
|
||
borderRadius: '0.25rem',
|
||
fontSize: '0.7rem'
|
||
}}>
|
||
{g.name}
|
||
</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')}
|
||
</td>
|
||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
||
{song.averageRating > 0 ? (
|
||
<span title={`${song.ratingCount} ratings`}>
|
||
{song.averageRating.toFixed(1)} ★ <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
|
||
</span>
|
||
) : (
|
||
<span style={{ color: '#ccc' }}>-</span>
|
||
)}
|
||
</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={7} 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 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>
|
||
);
|
||
}
|