Compare commits

..

6 Commits

Author SHA1 Message Date
Hördle Bot
702f47b7e5 Bump version to v0.1.6.7 2025-12-04 13:40:38 +01:00
Hördle Bot
86f3349f80 Fix duplicate toggleUploadSpecial definition in curator client 2025-12-04 13:40:23 +01:00
Hördle Bot
bdb74fb462 Bump version to v0.1.6.6 2025-12-04 13:36:40 +01:00
Hördle Bot
66c0071257 Allow curators to assign specials on upload and update help text 2025-12-04 13:36:24 +01:00
Hördle Bot
76f14087fd Bump version to v0.1.6.5 2025-12-04 13:27:48 +01:00
Hördle Bot
b1ab5bd633 Fix build by redirecting /curator/specials to localized route 2025-12-04 13:27:36 +01:00
6 changed files with 103 additions and 201 deletions

View File

@@ -107,6 +107,7 @@ export default function CuratorPageClient() {
// Upload state (analog zum Admin-Upload, aber vereinfacht) // Upload state (analog zum Admin-Upload, aber vereinfacht)
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]); const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
const [uploadSpecialIds, setUploadSpecialIds] = useState<number[]>([]);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number }>({ const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number }>({
@@ -534,6 +535,12 @@ export default function CuratorPageClient() {
); );
}; };
const toggleUploadSpecial = (specialId: number) => {
setUploadSpecialIds(prev =>
prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId]
);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []); const selected = Array.from(e.target.files || []);
if (selected.length === 0) return; if (selected.length === 0) return;
@@ -636,8 +643,8 @@ export default function CuratorPageClient() {
setFiles([]); setFiles([]);
setIsUploading(false); setIsUploading(false);
// Genres den erfolgreich hochgeladenen Songs zuweisen // Genres/Specials den erfolgreich hochgeladenen Songs zuweisen
if (uploadGenreIds.length > 0) { if (uploadGenreIds.length > 0 || uploadSpecialIds.length > 0) {
const successfulUploads = results.filter(r => r.success && r.song); const successfulUploads = results.filter(r => r.success && r.song);
for (const result of successfulUploads) { for (const result of successfulUploads) {
try { try {
@@ -649,12 +656,13 @@ export default function CuratorPageClient() {
title: result.song.title, title: result.song.title,
artist: result.song.artist, artist: result.song.artist,
releaseYear: result.song.releaseYear, releaseYear: result.song.releaseYear,
genreIds: uploadGenreIds, genreIds: uploadGenreIds.length > 0 ? uploadGenreIds : undefined,
specialIds: uploadSpecialIds.length > 0 ? uploadSpecialIds : undefined,
}), }),
}); });
} catch { } catch {
// Fehler beim Genre-Assigning werden nur geloggt, nicht abgebrochen // Fehler beim Zuweisen werden nur geloggt, nicht abgebrochen
console.error(`Failed to assign genres to ${result.song.title}`); console.error(`Failed to assign genres/specials to ${result.song.title}`);
} }
} }
} }
@@ -1149,44 +1157,82 @@ export default function CuratorPageClient() {
)} )}
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div> <div>
<HelpTooltip <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
shortText={tHelp('tooltipGenreAssignmentShort')} <div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div>
longText={tHelp('tooltipGenreAssignmentLong')} <HelpTooltip
position="right" shortText={tHelp('tooltipGenreAssignmentShort')}
/> longText={tHelp('tooltipGenreAssignmentLong')}
</div> position="right"
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}> />
{genres </div>
.filter(g => curatorInfo?.genreIds?.includes(g.id)) <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
.map(genre => ( {genres
<label .filter(g => curatorInfo?.genreIds?.includes(g.id))
key={genre.id} .map(genre => (
style={{ <label
display: 'flex', key={genre.id}
alignItems: 'center', style={{
gap: '0.25rem', display: 'flex',
padding: '0.25rem 0.5rem', alignItems: 'center',
borderRadius: '999px', gap: '0.25rem',
background: uploadGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6', padding: '0.25rem 0.5rem',
fontSize: '0.8rem', borderRadius: '999px',
cursor: 'pointer', background: uploadGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
}} fontSize: '0.8rem',
> cursor: 'pointer',
<input }}
type="checkbox" >
checked={uploadGenreIds.includes(genre.id)} <input
onChange={() => toggleUploadGenre(genre.id)} type="checkbox"
/> checked={uploadGenreIds.includes(genre.id)}
{typeof genre.name === 'string' ? genre.name : genre.name?.de ?? genre.name?.en} onChange={() => toggleUploadGenre(genre.id)}
</label> />
))} {typeof genre.name === 'string' ? genre.name : genre.name?.de ?? genre.name?.en}
{curatorInfo && curatorInfo.genreIds.length === 0 && ( </label>
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}> ))}
{t('noAssignedGenres')} {curatorInfo && curatorInfo.genreIds.length === 0 && (
</span> <span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
)} {t('noAssignedGenres')}
</span>
)}
</div>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', marginTop: '0.5rem' }}>
<div style={{ fontWeight: 500 }}>{t('assignSpecialsLabel')}</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<label
key={special.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
borderRadius: '999px',
background: uploadSpecialIds.includes(special.id) ? '#fef3c7' : '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={uploadSpecialIds.includes(special.id)}
onChange={() => toggleUploadSpecial(special.id)}
/>
{typeof special.name === 'string'
? special.name
: special.name?.de ?? special.name?.en}
</label>
))}
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
export const dynamic = 'force-dynamic';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useRouter, usePathname } from 'next/navigation'; import { useParams, useRouter, usePathname } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';

View File

@@ -1,161 +1,13 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; // Root /curator/specials route without locale:
import { useLocale, useTranslations } from 'next-intl'; // redirect users to the default English locale version.
import { Link } from '@/lib/navigation';
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
import HelpTooltip from '@/components/HelpTooltip';
type LocalizedString = string | { de: string; en: string }; import { redirect } from 'next/navigation';
interface CuratorSpecialSummary {
id: number;
name: LocalizedString;
songCount: number;
}
export default function CuratorSpecialsPage() { export default function CuratorSpecialsPage() {
const t = useTranslations('Curator'); redirect('/en/curator/specials');
const tHelp = useTranslations('CuratorHelp');
const locale = useLocale();
const [specials, setSpecials] = useState<CuratorSpecialSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSpecials = async () => {
try {
setLoading(true);
const res = await fetch('/api/curator/specials', {
headers: getCuratorAuthHeaders(),
});
if (res.ok) {
const data = await res.json();
setSpecials(data);
} else if (res.status === 403) {
setError(t('noSpecialPermissions'));
} else {
setError('Failed to load specials');
}
} catch (e) {
setError('Failed to load specials');
} finally {
setLoading(false);
}
};
fetchSpecials();
}, [t]);
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('loadingData')}</p>
</div>
);
}
if (error) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{error}</p>
</div>
);
}
if (specials.length === 0) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{t('noSpecialsInScope')}</p>
<div style={{ marginTop: '1.5rem' }}>
<Link
href="/curator"
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
borderRadius: '0.5rem',
textDecoration: 'none',
color: '#111827',
fontSize: '0.9rem',
}}
>
{t('backToDashboard')}
</Link>
</div>
</div>
);
}
const resolveLocalized = (value: LocalizedString, locale: string): string => {
if (!value) return '';
if (typeof value === 'string') return value;
const loc = locale === 'de' || locale === 'en' ? locale : 'en';
return value[loc] ?? value.en ?? value.de;
};
return (
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{t('curateSpecialsTitle')}
</h1>
<HelpTooltip
shortText={tHelp('tooltipCurateSpecialsShort')}
longText={tHelp('tooltipCurateSpecialsLong')}
position="bottom"
/>
</div>
<Link
href="/curator"
style={{
padding: '0.5rem 1rem',
background: '#e5e7eb',
borderRadius: '0.5rem',
textDecoration: 'none',
color: '#111827',
fontSize: '0.9rem',
}}
>
{t('backToDashboard')}
</Link>
</div>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>
{t('curateSpecialsDescription')}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
{specials.map(special => (
<Link
key={special.id}
href={`/curator/specials/${special.id}`}
style={{
padding: '1rem',
borderRadius: '0.75rem',
border: '1px solid #e5e7eb',
background: 'white',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
textDecoration: 'none',
color: 'inherit',
}}
>
<div>
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: '0.25rem' }}>
{resolveLocalized(special.name, String(locale))}
</h2>
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
{t('curateSpecialSongCount', { count: special.songCount })}
</p>
</div>
<div style={{ marginTop: '0.75rem', textAlign: 'right', fontSize: '0.875rem', color: '#4f46e5' }}>
{t('curateSpecialOpen')}
</div>
</Link>
))}
</div>
</div>
);
} }

View File

@@ -203,6 +203,7 @@
"selectedFilesTitle": "Ausgewählte Dateien:", "selectedFilesTitle": "Ausgewählte Dateien:",
"uploadProgress": "Upload: {current} / {total}", "uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Genres zuordnen", "assignGenresLabel": "Genres zuordnen",
"assignSpecialsLabel": "Specials zuordnen",
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.", "noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
"uploadButtonIdle": "Upload starten", "uploadButtonIdle": "Upload starten",
"uploadButtonUploading": "Lade hoch...", "uploadButtonUploading": "Lade hoch...",
@@ -311,12 +312,12 @@
"uploadTitle": "Songs hochladen", "uploadTitle": "Songs hochladen",
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung", "uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
"uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen", "uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen",
"uploadStep2": "Ein oder mehrere Genres auswählen, um sie den hochgeladenen Songs zuzuordnen", "uploadStep2": "Ein oder mehrere Genres und falls passend Specials auswählen, um sie den hochgeladenen Songs zuzuordnen",
"uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen", "uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen",
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien", "uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
"uploadBestPracticesTitle": "Best Practices", "uploadBestPracticesTitle": "Best Practices",
"uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben", "uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben",
"uploadBestPractice2": "Passende Genres vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden", "uploadBestPractice2": "Passende Genres (und Specials) vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
"uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert", "uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert",
"tip": "Tipp", "tip": "Tipp",
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.", "uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",

View File

@@ -203,6 +203,7 @@
"selectedFilesTitle": "Selected files:", "selectedFilesTitle": "Selected files:",
"uploadProgress": "Upload: {current} / {total}", "uploadProgress": "Upload: {current} / {total}",
"assignGenresLabel": "Assign genres", "assignGenresLabel": "Assign genres",
"assignSpecialsLabel": "Assign specials",
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.", "noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
"uploadButtonIdle": "Start upload", "uploadButtonIdle": "Start upload",
"uploadButtonUploading": "Uploading...", "uploadButtonUploading": "Uploading...",
@@ -311,12 +312,12 @@
"uploadTitle": "Uploading Songs", "uploadTitle": "Uploading Songs",
"uploadStepsTitle": "Step-by-Step Guide", "uploadStepsTitle": "Step-by-Step Guide",
"uploadStep1": "Drag MP3 files into the upload area or click to select files", "uploadStep1": "Drag MP3 files into the upload area or click to select files",
"uploadStep2": "Select one or more genres to assign to the uploaded songs", "uploadStep2": "Select one or more genres and, if applicable, specials to assign to the uploaded songs",
"uploadStep3": "Click 'Start upload' to begin the upload process", "uploadStep3": "Click 'Start upload' to begin the upload process",
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files", "uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
"uploadBestPracticesTitle": "Best Practices", "uploadBestPracticesTitle": "Best Practices",
"uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction", "uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction",
"uploadBestPractice2": "Select appropriate genres before uploading to avoid manual assignment later", "uploadBestPractice2": "Select appropriate genres (and specials) before uploading to avoid manual assignment later",
"uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists", "uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists",
"tip": "Tip", "tip": "Tip",
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.", "uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.6.4", "version": "0.1.6.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",