Feat: AI-powered genre categorization with OpenRouter

This commit is contained in:
Hördle Bot
2025-11-22 11:55:50 +01:00
parent e56d7893d7
commit dc69fd1498
4 changed files with 479 additions and 2 deletions

View File

@@ -2,6 +2,14 @@
import { useState, useEffect } from 'react';
interface Genre {
id: number;
name: string;
_count?: {
songs: number;
};
}
interface Song {
id: number;
title: string;
@@ -9,6 +17,7 @@ interface Song {
filename: string;
createdAt: string;
activations: number;
genres: Genre[];
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
@@ -20,11 +29,22 @@ export default function AdminPage() {
const [file, setFile] = useState<File | null>(null);
const [message, setMessage] = useState('');
const [songs, setSongs] = useState<Song[]>([]);
const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState('');
// Edit state
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState('');
const [editGenreIds, setEditGenreIds] = 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');
@@ -45,6 +65,7 @@ export default function AdminPage() {
if (authToken === 'authenticated') {
setIsAuthenticated(true);
fetchSongs();
fetchGenres();
}
}, []);
@@ -57,6 +78,7 @@ export default function AdminPage() {
localStorage.setItem('hoerdle_admin_auth', 'authenticated');
setIsAuthenticated(true);
fetchSongs();
fetchGenres();
} else {
alert('Wrong password');
}
@@ -70,6 +92,69 @@ export default function AdminPage() {
}
};
const fetchGenres = async () => {
const res = await fetch('/api/genres');
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',
body: JSON.stringify({ name: newGenreName }),
});
if (res.ok) {
setNewGenreName('');
fetchGenres();
} else {
alert('Failed to create genre');
}
};
const deleteGenre = async (id: number) => {
if (!confirm('Delete this genre?')) return;
const res = await fetch('/api/genres', {
method: 'DELETE',
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 {
const res = await fetch('/api/categorize', {
method: 'POST',
});
if (res.ok) {
const data = await res.json();
setCategorizationResults(data);
fetchSongs(); // Refresh song list
fetchGenres(); // Refresh genre counts
} else {
const error = await res.json();
alert(`Categorization failed: ${error.error || 'Unknown error'}`);
}
} catch (error) {
alert('Failed to categorize songs');
console.error(error);
} finally {
setIsCategorizing(false);
}
};
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
@@ -104,29 +189,62 @@ export default function AdminPage() {
setMessage(statusMessage);
setFile(null);
setUploadedSong(data.song);
setUploadGenreIds([]); // Reset selection
fetchSongs();
} else {
setMessage('Upload failed.');
}
};
const saveUploadedSongGenres = async () => {
if (!uploadedSong) return;
const res = await fetch('/api/songs', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: uploadedSong.id,
title: uploadedSong.title,
artist: uploadedSong.artist,
genreIds: uploadGenreIds
}),
});
if (res.ok) {
setUploadedSong(null);
setUploadGenreIds([]);
fetchSongs();
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);
setEditGenreIds(song.genres.map(g => g.id));
};
const cancelEditing = () => {
setEditingId(null);
setEditTitle('');
setEditArtist('');
setEditGenreIds([]);
};
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 }),
body: JSON.stringify({
id,
title: editTitle,
artist: editArtist,
genreIds: editGenreIds
}),
});
if (res.ok) {
@@ -255,6 +373,124 @@ export default function AdminPage() {
<div className="admin-container">
<h1 className="title" style={{ marginBottom: '2rem' }}>Admin Dashboard</h1>
{/* 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' }}
/>
<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>
<button
onClick={() => deleteGenre(genre.id)}
style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#ef4444', fontWeight: 'bold' }}
>
×
</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 New Song</h2>
<form onSubmit={handleUpload}>
@@ -286,6 +522,41 @@ export default function AdminPage() {
</div>
)}
</form>
{/* Post-upload Genre Selection */}
{uploadedSong && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem', border: '1px solid #c7d2fe' }}>
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Assign Genres to "{uploadedSong.title}"</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
{genres.map(genre => (
<label key={genre.id} style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
background: 'white',
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
cursor: 'pointer',
border: uploadGenreIds.includes(genre.id) ? '1px solid #4f46e5' : '1px solid #e5e7eb'
}}>
<input
type="checkbox"
checked={uploadGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setUploadGenreIds([...uploadGenreIds, genre.id]);
} else {
setUploadGenreIds(uploadGenreIds.filter(id => id !== genre.id));
}
}}
/>
{genre.name}
</label>
))}
</div>
<button onClick={saveUploadedSongGenres} className="btn-primary">Save Genres</button>
</div>
)}
</div>
<div className="admin-card">
@@ -326,6 +597,7 @@ export default function AdminPage() {
>
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.75rem' }}>Genres</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('createdAt')}
@@ -361,6 +633,26 @@ export default function AdminPage() {
style={{ padding: '0.25rem' }}
/>
</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>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
</td>
@@ -388,6 +680,20 @@ export default function AdminPage() {
<>
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
<td style={{ padding: '0.75rem' }}>{song.artist}</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>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
</td>
@@ -423,7 +729,7 @@ export default function AdminPage() {
))}
{paginatedSongs.length === 0 && (
<tr>
<td colSpan={6} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
<td colSpan={7} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
</td>
</tr>