Curator: Lokalisierung und einstellbare Paginierung

This commit is contained in:
Hördle Bot
2025-12-03 13:09:20 +01:00
parent 8a102afc0e
commit 33f8080aa8
3 changed files with 262 additions and 104 deletions

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
interface Genre {
id: number;
@@ -56,6 +57,7 @@ function getCuratorUploadHeaders() {
}
export default function CuratorPage() {
const t = useTranslations('Curator');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -93,7 +95,7 @@ export default function CuratorPage() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedFilter, setSelectedFilter] = useState<string>('');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [itemsPerPage, setItemsPerPage] = useState(10);
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
@@ -125,7 +127,7 @@ export default function CuratorPage() {
setCuratorInfo(data);
localStorage.setItem('hoerdle_curator_is_global', String(data.isGlobalCurator));
} else {
setMessage('Fehler beim Laden der Kuratoren-Informationen.');
setMessage(t('loadCuratorError'));
}
};
@@ -137,7 +139,7 @@ export default function CuratorPage() {
const data: Song[] = await res.json();
setSongs(data);
} else {
setMessage('Fehler beim Laden der Songs.');
setMessage(t('loadSongsError'));
}
};
@@ -176,10 +178,10 @@ export default function CuratorPage() {
await bootstrapCuratorData();
} else {
const err = await res.json().catch(() => null);
setMessage(err?.error || 'Login fehlgeschlagen.');
setMessage(err?.error || t('loginFailed'));
}
} catch (e) {
setMessage('Netzwerkfehler beim Login.');
setMessage(t('loginNetworkError'));
}
};
@@ -240,13 +242,13 @@ export default function CuratorPage() {
if (res.ok) {
setEditingId(null);
await fetchSongs();
setMessage('Song erfolgreich aktualisiert.');
setMessage(t('songUpdated'));
} else {
const errText = await res.text();
setMessage(`Fehler beim Speichern: ${errText}`);
setMessage(t('saveError', { error: errText }));
}
} catch (e) {
setMessage('Netzwerkfehler beim Speichern.');
setMessage(t('saveNetworkError'));
}
};
@@ -274,10 +276,10 @@ export default function CuratorPage() {
const handleDelete = async (song: Song) => {
if (!canDeleteSong(song)) {
setMessage('Du darfst diesen Song nicht löschen.');
setMessage(t('noDeletePermission'));
return;
}
if (!confirm(`Möchtest du "${song.title}" wirklich löschen?`)) return;
if (!confirm(t('deleteConfirm', { title: song.title }))) return;
try {
const res = await fetch('/api/songs', {
@@ -287,13 +289,13 @@ export default function CuratorPage() {
});
if (res.ok) {
await fetchSongs();
setMessage('Song gelöscht.');
setMessage(t('songDeleted'));
} else {
const errText = await res.text();
setMessage(`Fehler beim Löschen: ${errText}`);
setMessage(t('deleteError', { error: errText }));
}
} catch (e) {
setMessage('Netzwerkfehler beim Löschen.');
setMessage(t('deleteNetworkError'));
}
};
@@ -317,7 +319,7 @@ export default function CuratorPage() {
audio.onerror = () => {
setPlayingSongId(null);
setAudioElement(null);
alert(`Audio-Datei konnte nicht geladen werden: ${song.filename}`);
alert(`Audio file could not be loaded: ${song.filename}`);
};
audio.play()
@@ -470,19 +472,19 @@ export default function CuratorPage() {
const duplicateCount = results.filter(r => r.isDuplicate).length;
const failedCount = results.filter(r => !r.success && !r.isDuplicate).length;
let msg = `${successCount}/${results.length} Uploads erfolgreich.`;
if (duplicateCount > 0) msg += `\n⚠️ ${duplicateCount} Duplikat(e) übersprungen.`;
if (failedCount > 0) msg += `\n${failedCount} fehlgeschlagen.`;
let msg = t('uploadSummary', { success: successCount, total: results.length });
if (duplicateCount > 0) msg += `\n` + t('uploadSummaryDuplicates', { count: duplicateCount });
if (failedCount > 0) msg += `\n` + t('uploadSummaryFailed', { count: failedCount });
setMessage(msg);
};
if (!isAuthenticated) {
return (
<main style={{ maxWidth: '480px', margin: '2rem auto', padding: '1rem' }}>
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>Kuratoren-Login</h1>
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>{t('loginTitle')}</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<label>
Benutzername
{t('loginUsername')}
<input
type="text"
value={username}
@@ -491,7 +493,7 @@ export default function CuratorPage() {
/>
</label>
<label>
Passwort
{t('loginPassword')}
<input
type="password"
value={password}
@@ -512,7 +514,7 @@ export default function CuratorPage() {
marginTop: '0.5rem',
}}
>
Einloggen
{t('loginButton')}
</button>
{message && (
<p style={{ color: '#b91c1c', marginTop: '0.5rem', whiteSpace: 'pre-line' }}>{message}</p>
@@ -599,8 +601,8 @@ export default function CuratorPage() {
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
{curatorInfo && (
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>
Eingeloggt als <strong>{curatorInfo.username}</strong>
{curatorInfo.isGlobalCurator && ' (Globaler Kurator)'}
{t('loggedInAs', { username: curatorInfo.username })}
{curatorInfo.isGlobalCurator && t('globalCuratorSuffix')}
</p>
)}
</div>
@@ -616,21 +618,19 @@ export default function CuratorPage() {
cursor: 'pointer',
}}
>
Abmelden
{t('logout')}
</button>
</header>
{loading && <p>Lade Daten...</p>}
{loading && <p>{t('loadingData')}</p>}
{message && (
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
)}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>Titel hochladen</h2>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>{t('uploadSectionTitle')}</h2>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert
(inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens
eines deiner Genres aus, um die Titel zuzuordnen.
{t('uploadSectionDescription')}
</p>
<form onSubmit={handleBatchUpload} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '640px' }}>
<div
@@ -651,9 +651,11 @@ export default function CuratorPage() {
>
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
{files.length > 0 ? `${files.length} Datei(en) ausgewählt` : 'MP3-Dateien hierher ziehen'}
{files.length > 0
? t('dropzoneTitleWithFiles', { count: files.length })
: t('dropzoneTitleEmpty')}
</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>oder klicken, um Dateien auszuwählen</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>{t('dropzoneSubtitle')}</p>
<input
ref={fileInputRef}
type="file"
@@ -666,7 +668,7 @@ export default function CuratorPage() {
{files.length > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>Ausgewählte Dateien:</p>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>{t('selectedFilesTitle')}</p>
<div
style={{
maxHeight: '160px',
@@ -696,7 +698,10 @@ export default function CuratorPage() {
}}
>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
Upload: {uploadProgress.current} / {uploadProgress.total}
{t('uploadProgress', {
current: uploadProgress.current,
total: uploadProgress.total,
})}
</p>
<div
style={{
@@ -723,7 +728,7 @@ export default function CuratorPage() {
)}
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>Genres zuordnen</div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignGenresLabel')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds.includes(g.id))
@@ -751,7 +756,7 @@ export default function CuratorPage() {
))}
{curatorInfo && curatorInfo.genreIds.length === 0 && (
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.
{t('noAssignedGenres')}
</span>
)}
</div>
@@ -770,7 +775,7 @@ export default function CuratorPage() {
alignSelf: 'flex-start',
}}
>
{isUploading ? 'Lade hoch...' : 'Upload starten'}
{isUploading ? t('uploadButtonUploading') : t('uploadButtonIdle')}
</button>
{uploadResults.length > 0 && (
@@ -784,14 +789,14 @@ export default function CuratorPage() {
}}
>
{uploadResults.map((r, idx) => (
<div key={idx} style={{ marginBottom: '0.25rem' }}>
<strong>{r.filename}</strong> {' '}
{r.success
? '✅ erfolgreich'
: r.isDuplicate
? `⚠️ Duplikat: ${r.error}`
: `❌ Fehler: ${r.error}`}
</div>
<div key={idx} style={{ marginBottom: '0.25rem' }}>
<strong>{r.filename}</strong> {' '}
{r.success
? t('uploadResultSuccess')
: r.isDuplicate
? t('uploadResultDuplicate', { error: r.error })
: t('uploadResultError', { error: r.error })}
</div>
))}
</div>
)}
@@ -800,19 +805,17 @@ export default function CuratorPage() {
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
Titel in deinen Genres & Specials ({filteredSongs.length} Titel)
{t('tracklistTitle', { count: filteredSongs.length })}
</h2>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind.
Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist.
Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.
{t('tracklistDescription')}
</p>
{/* Suche & Filter */}
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Nach Titel oder Artist suchen..."
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={e => {
setSearchQuery(e.target.value);
@@ -839,8 +842,8 @@ export default function CuratorPage() {
border: '1px solid #d1d5db',
}}
>
<option value="">Alle Inhalte</option>
<option value="no-global">🚫 Ohne Global</option>
<option value="">{t('filterAll')}</option>
<option value="no-global">{t('filterNoGlobal')}</option>
<optgroup label="Genres">
{genres
.filter(g => curatorInfo?.genreIds.includes(g.id))
@@ -882,13 +885,13 @@ export default function CuratorPage() {
fontSize: '0.85rem',
}}
>
Filter zurücksetzen
{t('filterReset')}
</button>
)}
</div>
{visibleSongs.length === 0 ? (
<p>Keine passenden Songs in deinen Genres/Specials gefunden.</p>
<p>{t('noSongsInScope')}</p>
) : (
<>
<div style={{ overflowX: 'auto' }}>
@@ -905,48 +908,48 @@ export default function CuratorPage() {
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('id')}
>
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
{t('columnId')} {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>Play</th>
<th style={{ padding: '0.5rem' }}>{t('columnPlay')}</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('title')}
>
Titel {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
{t('columnTitle')} {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('artist')}
>
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
{t('columnArtist')} {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('releaseYear')}
>
Jahr {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
{t('columnYear')} {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>Genres / Specials</th>
<th style={{ padding: '0.5rem' }}>{t('columnGenresSpecials')}</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('createdAt')}
>
Hinzugefügt {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
{t('columnAdded')} {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('activations')}
>
Aktivierungen {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
{t('columnActivations')} {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
onClick={() => handleSort('averageRating')}
>
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
{t('columnRating')} {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>Exclude Global</th>
<th style={{ padding: '0.5rem' }}>Aktionen</th>
<th style={{ padding: '0.5rem' }}>{t('columnExcludeGlobal')}</th>
<th style={{ padding: '0.5rem' }}>{t('columnActions')}</th>
</tr>
</thead>
<tbody>
@@ -974,8 +977,8 @@ export default function CuratorPage() {
}}
title={
playingSongId === song.id
? 'Pause'
: 'Abspielen'
? t('pause')
: t('play')
}
>
{playingSongId === song.id ? '⏸️' : '▶️'}
@@ -1150,9 +1153,9 @@ export default function CuratorPage() {
disabled={!curatorInfo?.isGlobalCurator}
/>
) : song.excludeFromGlobal ? (
'Ja'
t('excludeGlobalYes')
) : (
'Nein'
t('excludeGlobalNo')
)}
{!curatorInfo?.isGlobalCurator && (
<span
@@ -1162,7 +1165,7 @@ export default function CuratorPage() {
color: '#9ca3af',
}}
>
Nur globale Kuratoren dürfen dieses Flag ändern.
{t('excludeGlobalInfo')}
</span>
)}
</td>
@@ -1246,50 +1249,73 @@ export default function CuratorPage() {
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div
{/* Pagination & Page Size */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '0.75rem',
fontSize: '0.875rem',
gap: '0.75rem',
flexWrap: 'wrap',
}}
>
<button
type="button"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={page === 1}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '0.75rem',
fontSize: '0.875rem',
padding: '0.3rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: page === 1 ? '#f3f4f6' : '#fff',
cursor: page === 1 ? 'not-allowed' : 'pointer',
}}
>
<button
type="button"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={page === 1}
{t('paginationPrev')}
</button>
<span style={{ color: '#666' }}>
{t('paginationLabel', { page, total: totalPages })}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span>{t('pageSizeLabel')}</span>
<select
value={itemsPerPage}
onChange={e => {
const value = parseInt(e.target.value, 10) || 10;
const safeValue = Math.min(100, Math.max(1, value));
setItemsPerPage(safeValue);
setCurrentPage(1);
}}
style={{
padding: '0.3rem 0.6rem',
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: page === 1 ? '#f3f4f6' : '#fff',
cursor: page === 1 ? 'not-allowed' : 'pointer',
}}
>
Zurück
</button>
<span style={{ color: '#666' }}>
Seite {page} von {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: page === totalPages ? '#f3f4f6' : '#fff',
cursor: page === totalPages ? 'not-allowed' : 'pointer',
}}
>
Weiter
</button>
{[10, 25, 50, 100].map(size => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
)}
<button
type="button"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
background: page === totalPages ? '#f3f4f6' : '#fff',
cursor: page === totalPages ? 'not-allowed' : 'pointer',
}}
>
{t('paginationNext')}
</button>
</div>
</>
)}
</section>