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>

View File

@@ -167,6 +167,72 @@
"assignedSpecials": "Zugeordnete Specials",
"noCurators": "Noch keine Kuratoren angelegt."
},
"Curator": {
"loginTitle": "Kuratoren-Login",
"loginUsername": "Benutzername",
"loginPassword": "Passwort",
"loginButton": "Einloggen",
"logout": "Abmelden",
"loginFailed": "Login fehlgeschlagen.",
"loginNetworkError": "Netzwerkfehler beim Login.",
"loadCuratorError": "Fehler beim Laden der Kuratoren-Informationen.",
"loadSongsError": "Fehler beim Laden der Songs.",
"songUpdated": "Song erfolgreich aktualisiert.",
"saveError": "Fehler beim Speichern: {error}",
"saveNetworkError": "Netzwerkfehler beim Speichern.",
"noDeletePermission": "Du darfst diesen Song nicht löschen.",
"deleteConfirm": "Möchtest du \"{title}\" wirklich löschen?",
"songDeleted": "Song gelöscht.",
"deleteError": "Fehler beim Löschen: {error}",
"deleteNetworkError": "Netzwerkfehler beim Löschen.",
"uploadSectionTitle": "Titel hochladen",
"uploadSectionDescription": "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.",
"dropzoneTitleEmpty": "MP3-Dateien hierher ziehen",
"dropzoneTitleWithFiles": "{count} Datei(en) ausgewählt",
"dropzoneSubtitle": "oder klicken, um Dateien auszuwählen",
"selectedFilesTitle": "Ausgewählte Dateien:",
"uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Genres zuordnen",
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
"uploadButtonIdle": "Upload starten",
"uploadButtonUploading": "Lade hoch...",
"uploadSummary": "✅ {success}/{total} Uploads erfolgreich.",
"uploadSummaryDuplicates": "⚠️ {count} Duplikat(e) übersprungen.",
"uploadSummaryFailed": "❌ {count} fehlgeschlagen.",
"uploadResultSuccess": "✅ erfolgreich",
"uploadResultDuplicate": "⚠️ Duplikat: {error}",
"uploadResultError": "❌ Fehler: {error}",
"tracklistTitle": "Titel in deinen Genres & Specials ({count} Titel)",
"tracklistDescription": "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.",
"searchPlaceholder": "Nach Titel oder Artist suchen...",
"filterAll": "Alle Inhalte",
"filterNoGlobal": "🚫 Ohne Global",
"filterReset": "Filter zurücksetzen",
"noSongsInScope": "Keine passenden Songs in deinen Genres/Specials gefunden.",
"columnId": "ID",
"columnPlay": "Play",
"columnTitle": "Titel",
"columnArtist": "Artist",
"columnYear": "Jahr",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Hinzugefügt",
"columnActivations": "Aktivierungen",
"columnRating": "Rating",
"columnExcludeGlobal": "Exclude Global",
"columnActions": "Aktionen",
"play": "Abspielen",
"pause": "Pause",
"excludeGlobalYes": "Ja",
"excludeGlobalNo": "Nein",
"excludeGlobalInfo": "Nur globale Kuratoren dürfen dieses Flag ändern.",
"paginationPrev": "Zurück",
"paginationNext": "Weiter",
"paginationLabel": "Seite {page} von {total}",
"loadingData": "Lade Daten...",
"loggedInAs": "Eingeloggt als {username}",
"globalCuratorSuffix": " (Globaler Kurator)",
"pageSizeLabel": "Pro Seite:"
},
"About": {
"title": "Über Hördle & Impressum",
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",

View File

@@ -167,6 +167,72 @@
"assignedSpecials": "Assigned specials",
"noCurators": "No curators created yet."
},
"Curator": {
"loginTitle": "Curator Login",
"loginUsername": "Username",
"loginPassword": "Password",
"loginButton": "Log in",
"logout": "Logout",
"loginFailed": "Login failed.",
"loginNetworkError": "Network error during login.",
"loadCuratorError": "Failed to load curator information.",
"loadSongsError": "Failed to load songs.",
"songUpdated": "Song updated successfully.",
"saveError": "Error while saving: {error}",
"saveNetworkError": "Network error while saving.",
"noDeletePermission": "You are not allowed to delete this song.",
"deleteConfirm": "Do you really want to delete \"{title}\"?",
"songDeleted": "Song deleted.",
"deleteError": "Error while deleting: {error}",
"deleteNetworkError": "Network error while deleting.",
"uploadSectionTitle": "Upload titles",
"uploadSectionDescription": "Drag one or more MP3 files here or select them. The titles will be analysed automatically (including detection of the release year) and excluded from the global playlist. Select at least one of your genres to assign the titles.",
"dropzoneTitleEmpty": "Drag MP3 files here",
"dropzoneTitleWithFiles": "{count} file(s) selected",
"dropzoneSubtitle": "or click to select files",
"selectedFilesTitle": "Selected files:",
"uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Assign genres",
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
"uploadButtonIdle": "Start upload",
"uploadButtonUploading": "Uploading...",
"uploadSummary": "✅ {success}/{total} uploads successful.",
"uploadSummaryDuplicates": "⚠️ {count} duplicate(s) skipped.",
"uploadSummaryFailed": "❌ {count} failed.",
"uploadResultSuccess": "✅ successful",
"uploadResultDuplicate": "⚠️ Duplicate: {error}",
"uploadResultError": "❌ Error: {error}",
"tracklistTitle": "Titles in your genres & specials ({count} titles)",
"tracklistDescription": "You can edit songs that are assigned to at least one of your genres or specials. Deletion is only allowed if a song is assigned exclusively to your genres/specials. Genres, specials, news and political statements can only be managed by the admin.",
"searchPlaceholder": "Search by title or artist...",
"filterAll": "All content",
"filterNoGlobal": "🚫 No global",
"filterReset": "Reset filters",
"noSongsInScope": "No matching songs in your genres/specials.",
"columnId": "ID",
"columnPlay": "Play",
"columnTitle": "Title",
"columnArtist": "Artist",
"columnYear": "Year",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Added",
"columnActivations": "Activations",
"columnRating": "Rating",
"columnExcludeGlobal": "Exclude global",
"columnActions": "Actions",
"play": "Play",
"pause": "Pause",
"excludeGlobalYes": "Yes",
"excludeGlobalNo": "No",
"excludeGlobalInfo": "Only global curators may change this flag.",
"paginationPrev": "Previous",
"paginationNext": "Next",
"paginationLabel": "Page {page} of {total}",
"loadingData": "Loading data...",
"loggedInAs": "Logged in as {username}",
"globalCuratorSuffix": " (Global curator)",
"pageSizeLabel": "Per page:"
},
"About": {
"title": "About Hördle & Imprint",
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",