Compare commits
6 Commits
51c62e7763
...
v0.1.6.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
702f47b7e5 | ||
|
|
86f3349f80 | ||
|
|
bdb74fb462 | ||
|
|
66c0071257 | ||
|
|
76f14087fd | ||
|
|
b1ab5bd633 |
@@ -107,6 +107,7 @@ export default function CuratorPageClient() {
|
||||
// Upload state (analog zum Admin-Upload, aber vereinfacht)
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||||
const [uploadSpecialIds, setUploadSpecialIds] = useState<number[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
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 selected = Array.from(e.target.files || []);
|
||||
if (selected.length === 0) return;
|
||||
@@ -636,8 +643,8 @@ export default function CuratorPageClient() {
|
||||
setFiles([]);
|
||||
setIsUploading(false);
|
||||
|
||||
// Genres den erfolgreich hochgeladenen Songs zuweisen
|
||||
if (uploadGenreIds.length > 0) {
|
||||
// Genres/Specials den erfolgreich hochgeladenen Songs zuweisen
|
||||
if (uploadGenreIds.length > 0 || uploadSpecialIds.length > 0) {
|
||||
const successfulUploads = results.filter(r => r.success && r.song);
|
||||
for (const result of successfulUploads) {
|
||||
try {
|
||||
@@ -649,12 +656,13 @@ export default function CuratorPageClient() {
|
||||
title: result.song.title,
|
||||
artist: result.song.artist,
|
||||
releaseYear: result.song.releaseYear,
|
||||
genreIds: uploadGenreIds,
|
||||
genreIds: uploadGenreIds.length > 0 ? uploadGenreIds : undefined,
|
||||
specialIds: uploadSpecialIds.length > 0 ? uploadSpecialIds : undefined,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Fehler beim Genre-Assigning werden nur geloggt, nicht abgebrochen
|
||||
console.error(`Failed to assign genres to ${result.song.title}`);
|
||||
// Fehler beim Zuweisen werden nur geloggt, nicht abgebrochen
|
||||
console.error(`Failed to assign genres/specials to ${result.song.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1149,44 +1157,82 @@ export default function CuratorPageClient() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipGenreAssignmentShort')}
|
||||
longText={tHelp('tooltipGenreAssignmentLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{genres
|
||||
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||
.map(genre => (
|
||||
<label
|
||||
key={genre.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '999px',
|
||||
background: uploadGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadGenreIds.includes(genre.id)}
|
||||
onChange={() => toggleUploadGenre(genre.id)}
|
||||
/>
|
||||
{typeof genre.name === 'string' ? genre.name : genre.name?.de ?? genre.name?.en}
|
||||
</label>
|
||||
))}
|
||||
{curatorInfo && curatorInfo.genreIds.length === 0 && (
|
||||
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
|
||||
{t('noAssignedGenres')}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipGenreAssignmentShort')}
|
||||
longText={tHelp('tooltipGenreAssignmentLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{genres
|
||||
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||
.map(genre => (
|
||||
<label
|
||||
key={genre.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '999px',
|
||||
background: uploadGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadGenreIds.includes(genre.id)}
|
||||
onChange={() => toggleUploadGenre(genre.id)}
|
||||
/>
|
||||
{typeof genre.name === 'string' ? genre.name : genre.name?.de ?? genre.name?.en}
|
||||
</label>
|
||||
))}
|
||||
{curatorInfo && curatorInfo.genreIds.length === 0 && (
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
@@ -1,161 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||
import HelpTooltip from '@/components/HelpTooltip';
|
||||
// Root /curator/specials route without locale:
|
||||
// redirect users to the default English locale version.
|
||||
|
||||
type LocalizedString = string | { de: string; en: string };
|
||||
|
||||
interface CuratorSpecialSummary {
|
||||
id: number;
|
||||
name: LocalizedString;
|
||||
songCount: number;
|
||||
}
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function CuratorSpecialsPage() {
|
||||
const t = useTranslations('Curator');
|
||||
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>
|
||||
);
|
||||
redirect('/en/curator/specials');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
"selectedFilesTitle": "Ausgewählte Dateien:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Genres zuordnen",
|
||||
"assignSpecialsLabel": "Specials zuordnen",
|
||||
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
|
||||
"uploadButtonIdle": "Upload starten",
|
||||
"uploadButtonUploading": "Lade hoch...",
|
||||
@@ -311,12 +312,12 @@
|
||||
"uploadTitle": "Songs hochladen",
|
||||
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
|
||||
"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",
|
||||
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
|
||||
"uploadBestPracticesTitle": "Best Practices",
|
||||
"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",
|
||||
"tip": "Tipp",
|
||||
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
"selectedFilesTitle": "Selected files:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Assign genres",
|
||||
"assignSpecialsLabel": "Assign specials",
|
||||
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
|
||||
"uploadButtonIdle": "Start upload",
|
||||
"uploadButtonUploading": "Uploading...",
|
||||
@@ -311,12 +312,12 @@
|
||||
"uploadTitle": "Uploading Songs",
|
||||
"uploadStepsTitle": "Step-by-Step Guide",
|
||||
"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",
|
||||
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
|
||||
"uploadBestPracticesTitle": "Best Practices",
|
||||
"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",
|
||||
"tip": "Tip",
|
||||
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.6.4",
|
||||
"version": "0.1.6.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user