From 33f8080aa870ec772dd4f7e2464e64e2bc0f6ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Wed, 3 Dec 2025 13:09:20 +0100 Subject: [PATCH] Curator: Lokalisierung und einstellbare Paginierung --- app/curator/page.tsx | 234 ++++++++++++++++++++++++------------------- messages/de.json | 66 ++++++++++++ messages/en.json | 66 ++++++++++++ 3 files changed, 262 insertions(+), 104 deletions(-) diff --git a/app/curator/page.tsx b/app/curator/page.tsx index 07186a7..210a003 100644 --- a/app/curator/page.tsx +++ b/app/curator/page.tsx @@ -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(''); const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 10; + const [itemsPerPage, setItemsPerPage] = useState(10); const [playingSongId, setPlayingSongId] = useState(null); const [audioElement, setAudioElement] = useState(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 (
-

Kuratoren-Login

+

{t('loginTitle')}

@@ -616,21 +618,19 @@ export default function CuratorPage() { cursor: 'pointer', }} > - Abmelden + {t('logout')} - {loading &&

Lade Daten...

} + {loading &&

{t('loadingData')}

} {message && (

{message}

)}
-

Titel hochladen

+

{t('uploadSectionTitle')}

- 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')}

📁

- {files.length > 0 ? `${files.length} Datei(en) ausgewählt` : 'MP3-Dateien hierher ziehen'} + {files.length > 0 + ? t('dropzoneTitleWithFiles', { count: files.length }) + : t('dropzoneTitleEmpty')}

-

oder klicken, um Dateien auszuwählen

+

{t('dropzoneSubtitle')}

0 && (
-

Ausgewählte Dateien:

+

{t('selectedFilesTitle')}

- Upload: {uploadProgress.current} / {uploadProgress.total} + {t('uploadProgress', { + current: uploadProgress.current, + total: uploadProgress.total, + })}

-
Genres zuordnen
+
{t('assignGenresLabel')}
{genres .filter(g => curatorInfo?.genreIds.includes(g.id)) @@ -751,7 +756,7 @@ export default function CuratorPage() { ))} {curatorInfo && curatorInfo.genreIds.length === 0 && ( - Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin. + {t('noAssignedGenres')} )}
@@ -770,7 +775,7 @@ export default function CuratorPage() { alignSelf: 'flex-start', }} > - {isUploading ? 'Lade hoch...' : 'Upload starten'} + {isUploading ? t('uploadButtonUploading') : t('uploadButtonIdle')} {uploadResults.length > 0 && ( @@ -784,14 +789,14 @@ export default function CuratorPage() { }} > {uploadResults.map((r, idx) => ( -
- {r.filename} –{' '} - {r.success - ? '✅ erfolgreich' - : r.isDuplicate - ? `⚠️ Duplikat: ${r.error}` - : `❌ Fehler: ${r.error}`} -
+
+ {r.filename} –{' '} + {r.success + ? t('uploadResultSuccess') + : r.isDuplicate + ? t('uploadResultDuplicate', { error: r.error }) + : t('uploadResultError', { error: r.error })} +
))}
)} @@ -800,19 +805,17 @@ export default function CuratorPage() {

- Titel in deinen Genres & Specials ({filteredSongs.length} Titel) + {t('tracklistTitle', { count: filteredSongs.length })}

- 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')}

{/* Suche & Filter */}
{ setSearchQuery(e.target.value); @@ -839,8 +842,8 @@ export default function CuratorPage() { border: '1px solid #d1d5db', }} > - - + + {genres .filter(g => curatorInfo?.genreIds.includes(g.id)) @@ -882,13 +885,13 @@ export default function CuratorPage() { fontSize: '0.85rem', }} > - Filter zurücksetzen + {t('filterReset')} )}
{visibleSongs.length === 0 ? ( -

Keine passenden Songs in deinen Genres/Specials gefunden.

+

{t('noSongsInScope')}

) : ( <>
@@ -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' ? '↑' : '↓')} - Play + {t('columnPlay')} handleSort('title')} > - Titel {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')} + {t('columnTitle')} {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('artist')} > - Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')} + {t('columnArtist')} {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('releaseYear')} > - Jahr {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')} + {t('columnYear')} {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')} - Genres / Specials + {t('columnGenresSpecials')} handleSort('createdAt')} > - Hinzugefügt {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')} + {t('columnAdded')} {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('activations')} > - Aktivierungen {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')} + {t('columnActivations')} {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('averageRating')} > - Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')} + {t('columnRating')} {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')} - Exclude Global - Aktionen + {t('columnExcludeGlobal')} + {t('columnActions')} @@ -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 && ( - Nur globale Kuratoren dürfen dieses Flag ändern. + {t('excludeGlobalInfo')} )} @@ -1246,50 +1249,73 @@ export default function CuratorPage() {
- {/* Pagination */} - {totalPages > 1 && ( -
+ + + {t('paginationLabel', { page, total: totalPages })} + +
+ {t('pageSizeLabel')} +
- )} + +
)}
diff --git a/messages/de.json b/messages/de.json index 02f312c..5cb2847 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index f4fe36c..b37e6df 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.",