Kuratoren-Accounts und Anpassungen im Admin- und Kuratoren-Dashboard
This commit is contained in:
@@ -76,6 +76,14 @@ interface PoliticalStatement {
|
|||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Curator {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
isGlobalCurator: boolean;
|
||||||
|
genreIds: number[];
|
||||||
|
specialIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
@@ -159,14 +167,18 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
const [sortField, setSortField] = useState<SortField>('artist');
|
const [sortField, setSortField] = useState<SortField>('artist');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
|
||||||
// Search and pagination state
|
// Search and pagination state (wird nur noch in Resten der alten Song Library verwendet, kann später entfernt werden)
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
|
const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
|
||||||
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
|
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 10;
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
// Audio state
|
// Legacy Song-Library-Helper (Liste selbst ist obsolet; wir halten diese Werte nur, damit altes JSX nicht crasht)
|
||||||
|
const paginatedSongs: Song[] = [];
|
||||||
|
const totalPages = 1;
|
||||||
|
|
||||||
|
// Audio state (für Daily Puzzles)
|
||||||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
@@ -184,6 +196,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
|
const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Curators state
|
||||||
|
const [curators, setCurators] = useState<Curator[]>([]);
|
||||||
|
const [showCurators, setShowCurators] = useState(false);
|
||||||
|
const [editingCuratorId, setEditingCuratorId] = useState<number | null>(null);
|
||||||
|
const [curatorUsername, setCuratorUsername] = useState('');
|
||||||
|
const [curatorPassword, setCuratorPassword] = useState('');
|
||||||
|
const [curatorIsGlobal, setCuratorIsGlobal] = useState(false);
|
||||||
|
const [curatorGenreIds, setCuratorGenreIds] = useState<number[]>([]);
|
||||||
|
const [curatorSpecialIds, setCuratorSpecialIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// Check for existing auth on mount
|
// Check for existing auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
const authToken = localStorage.getItem('hoerdle_admin_auth');
|
||||||
@@ -194,6 +216,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
fetchDailyPuzzles();
|
fetchDailyPuzzles();
|
||||||
fetchSpecials();
|
fetchSpecials();
|
||||||
fetchNews();
|
fetchNews();
|
||||||
|
fetchCurators();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -210,6 +233,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
fetchDailyPuzzles();
|
fetchDailyPuzzles();
|
||||||
fetchSpecials();
|
fetchSpecials();
|
||||||
fetchNews();
|
fetchNews();
|
||||||
|
fetchCurators();
|
||||||
} else {
|
} else {
|
||||||
alert(t('wrongPassword'));
|
alert(t('wrongPassword'));
|
||||||
}
|
}
|
||||||
@@ -224,6 +248,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
setGenres([]);
|
setGenres([]);
|
||||||
setSpecials([]);
|
setSpecials([]);
|
||||||
setDailyPuzzles([]);
|
setDailyPuzzles([]);
|
||||||
|
setCurators([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to add auth headers to requests
|
// Helper function to add auth headers to requests
|
||||||
@@ -245,6 +270,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCurators = async () => {
|
||||||
|
const res = await fetch('/api/curators', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setCurators(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchGenres = async () => {
|
const fetchGenres = async () => {
|
||||||
const res = await fetch('/api/genres', {
|
const res = await fetch('/api/genres', {
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
@@ -719,6 +754,85 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetCuratorForm = () => {
|
||||||
|
setEditingCuratorId(null);
|
||||||
|
setCuratorUsername('');
|
||||||
|
setCuratorPassword('');
|
||||||
|
setCuratorIsGlobal(false);
|
||||||
|
setCuratorGenreIds([]);
|
||||||
|
setCuratorSpecialIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditCurator = (curator: Curator) => {
|
||||||
|
setEditingCuratorId(curator.id);
|
||||||
|
setCuratorUsername(curator.username);
|
||||||
|
setCuratorPassword('');
|
||||||
|
setCuratorIsGlobal(curator.isGlobalCurator);
|
||||||
|
setCuratorGenreIds(curator.genreIds || []);
|
||||||
|
setCuratorSpecialIds(curator.specialIds || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCuratorGenre = (genreId: number) => {
|
||||||
|
setCuratorGenreIds(prev =>
|
||||||
|
prev.includes(genreId) ? prev.filter(id => id !== genreId) : [...prev, genreId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCuratorSpecial = (specialId: number) => {
|
||||||
|
setCuratorSpecialIds(prev =>
|
||||||
|
prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCurator = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!curatorUsername.trim()) return;
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
username: curatorUsername.trim(),
|
||||||
|
isGlobalCurator: curatorIsGlobal,
|
||||||
|
genreIds: curatorGenreIds,
|
||||||
|
specialIds: curatorSpecialIds,
|
||||||
|
};
|
||||||
|
if (curatorPassword.trim()) {
|
||||||
|
payload.password = curatorPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = '/api/curators';
|
||||||
|
const method = editingCuratorId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
if (editingCuratorId) {
|
||||||
|
payload.id = editingCuratorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
resetCuratorForm();
|
||||||
|
fetchCurators();
|
||||||
|
} else {
|
||||||
|
alert('Failed to save curator');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCurator = async (id: number) => {
|
||||||
|
if (!confirm('Kurator wirklich löschen?')) return;
|
||||||
|
const res = await fetch('/api/curators', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchCurators();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete curator');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBatchUpload = async (e: React.FormEvent) => {
|
const handleBatchUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
@@ -1019,15 +1133,6 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
if (sortField === field) {
|
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortDirection('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayPause = (song: Song) => {
|
const handlePlayPause = (song: Song) => {
|
||||||
if (playingSongId === song.id) {
|
if (playingSongId === song.id) {
|
||||||
// Pause current song
|
// Pause current song
|
||||||
@@ -1067,70 +1172,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter and sort songs
|
// Song Library ist in das Kuratoren-Dashboard umgezogen, daher keine Song-Filter/Pagination mehr im Admin nötig.
|
||||||
const filteredSongs = songs.filter(song => {
|
|
||||||
// Text search filter
|
|
||||||
const matchesSearch = song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
|
|
||||||
// Genre filter
|
|
||||||
// Unified Filter
|
|
||||||
let matchesFilter = true;
|
|
||||||
if (selectedGenreFilter) {
|
|
||||||
if (selectedGenreFilter.startsWith('genre:')) {
|
|
||||||
const genreId = Number(selectedGenreFilter.split(':')[1]);
|
|
||||||
matchesFilter = genreId === -1
|
|
||||||
? song.genres.length === 0
|
|
||||||
: song.genres.some(g => g.id === genreId);
|
|
||||||
} else if (selectedGenreFilter.startsWith('special:')) {
|
|
||||||
const specialId = Number(selectedGenreFilter.split(':')[1]);
|
|
||||||
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
|
|
||||||
} else if (selectedGenreFilter === 'daily') {
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
|
|
||||||
} else if (selectedGenreFilter === 'no-global') {
|
|
||||||
matchesFilter = song.excludeFromGlobal === true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchesSearch && matchesFilter;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
|
||||||
// Handle numeric sorting for ID, Release Year, Activations, and Rating
|
|
||||||
if (sortField === 'id') {
|
|
||||||
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
|
||||||
}
|
|
||||||
if (sortField === 'releaseYear') {
|
|
||||||
const yearA = a.releaseYear || 0;
|
|
||||||
const yearB = b.releaseYear || 0;
|
|
||||||
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
|
|
||||||
}
|
|
||||||
if (sortField === 'activations') {
|
|
||||||
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
|
|
||||||
}
|
|
||||||
if (sortField === 'averageRating') {
|
|
||||||
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
|
|
||||||
}
|
|
||||||
|
|
||||||
// String sorting for other fields
|
|
||||||
const valA = String(a[sortField]).toLowerCase();
|
|
||||||
const valB = String(b[sortField]).toLowerCase();
|
|
||||||
|
|
||||||
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
|
|
||||||
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const totalPages = Math.ceil(sortedSongs.length / itemsPerPage);
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
const paginatedSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage);
|
|
||||||
|
|
||||||
// Reset to page 1 when search changes
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
@@ -1826,155 +1868,193 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Curator Management */}
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>{t('uploadSongs')}</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
<form onSubmit={handleBatchUpload}>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
|
||||||
{/* Drag & Drop Zone */}
|
{t('manageCurators')}
|
||||||
<div
|
</h2>
|
||||||
onDragEnter={handleDragEnter}
|
<button
|
||||||
onDragOver={handleDragOver}
|
onClick={() => setShowCurators(!showCurators)}
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
style={{
|
style={{
|
||||||
border: isDragging ? '2px solid #4f46e5' : '2px dashed #d1d5db',
|
padding: '0.5rem 1rem',
|
||||||
borderRadius: '0.5rem',
|
background: '#f3f4f6',
|
||||||
padding: '2rem',
|
border: '1px solid #d1d5db',
|
||||||
textAlign: 'center',
|
borderRadius: '0.25rem',
|
||||||
background: isDragging ? '#eef2ff' : '#f9fafb',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
fontSize: '0.875rem'
|
||||||
}}
|
}}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
|
{showCurators ? t('hide') : t('show')}
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
</button>
|
||||||
{files.length > 0 ? `${files.length} file(s) selected` : 'Drag & drop MP3 files here'}
|
</div>
|
||||||
</p>
|
{showCurators && (
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
<>
|
||||||
or click to browse
|
<form onSubmit={handleSaveCurator} style={{ marginBottom: '1rem' }}>
|
||||||
</p>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
<input
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
ref={fileInputRef}
|
<input
|
||||||
type="file"
|
type="text"
|
||||||
accept="audio/mpeg"
|
value={curatorUsername}
|
||||||
multiple
|
onChange={e => setCuratorUsername(e.target.value)}
|
||||||
onChange={handleFileChange}
|
placeholder={t('curatorUsername')}
|
||||||
style={{ display: 'none' }}
|
className="form-input"
|
||||||
/>
|
style={{ minWidth: '200px', flex: '1 1 200px' }}
|
||||||
</div>
|
required
|
||||||
|
/>
|
||||||
{/* File List */}
|
<input
|
||||||
{files.length > 0 && (
|
type="password"
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
value={curatorPassword}
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Selected Files:</p>
|
onChange={e => setCuratorPassword(e.target.value)}
|
||||||
<div style={{ maxHeight: '200px', overflowY: 'auto', background: '#f9fafb', padding: '0.5rem', borderRadius: '0.25rem' }}>
|
placeholder={t('curatorPassword')}
|
||||||
{files.map((file, index) => (
|
className="form-input"
|
||||||
<div key={index} style={{ padding: '0.25rem 0', fontSize: '0.875rem' }}>
|
style={{ minWidth: '200px', flex: '1 1 200px' }}
|
||||||
📄 {file.name}
|
/>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={curatorIsGlobal}
|
||||||
|
onChange={e => setCuratorIsGlobal(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('isGlobalCurator')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedGenres')}</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{genres.map(genre => (
|
||||||
|
<label
|
||||||
|
key={genre.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: curatorGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={curatorGenreIds.includes(genre.id)}
|
||||||
|
onChange={() => toggleCuratorGenre(genre.id)}
|
||||||
|
/>
|
||||||
|
{typeof genre.name === 'string'
|
||||||
|
? genre.name
|
||||||
|
: getLocalizedValue(genre.name, activeTab)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div style={{ flex: '1 1 200px' }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignedSpecials')}</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{specials.map(special => (
|
||||||
|
<label
|
||||||
|
key={special.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: curatorSpecialIds.includes(special.id) ? '#fee2e2' : '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={curatorSpecialIds.includes(special.id)}
|
||||||
|
onChange={() => toggleCuratorSpecial(special.id)}
|
||||||
|
/>
|
||||||
|
{typeof special.name === 'string'
|
||||||
|
? special.name
|
||||||
|
: getLocalizedValue(special.name, activeTab)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{editingCuratorId ? t('save') : t('addCurator')}
|
||||||
|
</button>
|
||||||
|
{editingCuratorId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={resetCuratorForm}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload Progress */}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
{isUploading && (
|
{curators.length === 0 && (
|
||||||
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem' }}>
|
<p style={{ color: '#666', fontSize: '0.875rem' }}>{t('noCurators')}</p>
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
)}
|
||||||
Uploading: {uploadProgress.current} / {uploadProgress.total}
|
{curators.map(curator => (
|
||||||
</p>
|
<div
|
||||||
<div style={{ width: '100%', height: '8px', background: '#d1d5db', borderRadius: '4px', overflow: 'hidden' }}>
|
key={curator.id}
|
||||||
<div style={{
|
|
||||||
width: `${(uploadProgress.current / uploadProgress.total) * 100}%`,
|
|
||||||
height: '100%',
|
|
||||||
background: '#4f46e5',
|
|
||||||
transition: 'width 0.3s'
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
|
|
||||||
Assign Genres (optional)
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
||||||
{genres.map(genre => (
|
|
||||||
<label
|
|
||||||
key={genre.id}
|
|
||||||
style={{
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
background: curator.isGlobalCurator ? '#eff6ff' : '#f9fafb',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.25rem',
|
gap: '0.5rem',
|
||||||
padding: '0.25rem 0.5rem',
|
flexWrap: 'wrap'
|
||||||
background: batchUploadGenreIds.includes(genre.id) ? '#dbeafe' : '#f3f4f6',
|
|
||||||
border: batchUploadGenreIds.includes(genre.id) ? '2px solid #3b82f6' : '2px solid transparent',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
type="checkbox"
|
<div style={{ fontWeight: 600 }}>{curator.username}</div>
|
||||||
checked={batchUploadGenreIds.includes(genre.id)}
|
<div style={{ fontSize: '0.8rem', color: '#4b5563' }}>
|
||||||
onChange={e => {
|
{curator.isGlobalCurator && <span>Globaler Kurator · </span>}
|
||||||
if (e.target.checked) {
|
<span>
|
||||||
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
|
{t('assignedGenres')}: {curator.genreIds.length}
|
||||||
} else {
|
</span>
|
||||||
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
|
{' · '}
|
||||||
}
|
<span>
|
||||||
}}
|
{t('assignedSpecials')}: {curator.specialIds.length}
|
||||||
style={{ margin: 0 }}
|
</span>
|
||||||
/>
|
</div>
|
||||||
{getLocalizedValue(genre.name, activeTab)}
|
</div>
|
||||||
</label>
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => startEditCurator(curator)}
|
||||||
|
>
|
||||||
|
{t('edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger"
|
||||||
|
style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => handleDeleteCurator(curator.id)}
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
|
</>
|
||||||
Selected genres will be assigned to all uploaded songs.
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={uploadExcludeFromGlobal}
|
|
||||||
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
|
|
||||||
style={{ width: '1.25rem', height: '1.25rem' }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
|
|
||||||
</label>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
|
|
||||||
If checked, these songs will only appear in Genre or Special puzzles.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary"
|
|
||||||
disabled={files.length === 0 || isUploading}
|
|
||||||
style={{ opacity: files.length === 0 || isUploading ? 0.5 : 1 }}
|
|
||||||
>
|
|
||||||
{isUploading ? 'Uploading...' : `Upload ${files.length} Song(s)`}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div style={{
|
|
||||||
marginTop: '1rem',
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: '#d1fae5',
|
|
||||||
color: '#065f46',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Songs wurde in das Kuratoren-Dashboard verlagert */}
|
||||||
|
|
||||||
{/* Today's Daily Puzzles */}
|
{/* Today's Daily Puzzles */}
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
@@ -2049,397 +2129,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-card">
|
{/* Song Library wurde in das Kuratoren-Dashboard verlagert */}
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
Song Library ({songs.length} songs)
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Search and Filter */}
|
|
||||||
<div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by title or artist..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
style={{ flex: '1', minWidth: '200px' }}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={selectedGenreFilter}
|
|
||||||
onChange={e => setSelectedGenreFilter(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
style={{ minWidth: '150px' }}
|
|
||||||
>
|
|
||||||
<option value="">All Content</option>
|
|
||||||
<option value="daily">📅 Song of the Day</option>
|
|
||||||
<option value="no-global">🚫 No Global</option>
|
|
||||||
<optgroup label="Genres">
|
|
||||||
<option value="genre:-1">No Genre</option>
|
|
||||||
{genres.map(genre => (
|
|
||||||
<option key={genre.id} value={`genre:${genre.id}`}>
|
|
||||||
{getLocalizedValue(genre.name, activeTab)} ({genre._count?.songs || 0})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Specials">
|
|
||||||
{specials.map(special => (
|
|
||||||
<option key={special.id} value={`special:${special.id}`}>
|
|
||||||
★ {getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
{(searchQuery || selectedGenreFilter) && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('');
|
|
||||||
setSelectedGenreFilter('');
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
background: '#f3f4f6',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.875rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear Filters
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('id')}
|
|
||||||
>
|
|
||||||
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('title')}
|
|
||||||
>
|
|
||||||
Song {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('releaseYear')}
|
|
||||||
>
|
|
||||||
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('createdAt')}
|
|
||||||
>
|
|
||||||
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('activations')}
|
|
||||||
>
|
|
||||||
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
onClick={() => handleSort('averageRating')}
|
|
||||||
>
|
|
||||||
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
|
|
||||||
</th>
|
|
||||||
<th style={{ padding: '0.75rem' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{paginatedSongs.map(song => (
|
|
||||||
<tr key={song.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
|
||||||
<td style={{ padding: '0.75rem' }}>{song.id}</td>
|
|
||||||
|
|
||||||
{editingId === song.id ? (
|
|
||||||
<>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editTitle}
|
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
style={{ padding: '0.25rem', marginBottom: '0.5rem', width: '100%' }}
|
|
||||||
placeholder="Title"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editArtist}
|
|
||||||
onChange={e => setEditArtist(e.target.value)}
|
|
||||||
className="form-input"
|
|
||||||
style={{ padding: '0.25rem', width: '100%' }}
|
|
||||||
placeholder="Artist"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editReleaseYear}
|
|
||||||
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
|
|
||||||
className="form-input"
|
|
||||||
style={{ padding: '0.25rem', width: '80px' }}
|
|
||||||
placeholder="Year"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
|
||||||
{genres.map(genre => (
|
|
||||||
<label key={genre.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editGenreIds.includes(genre.id)}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setEditGenreIds([...editGenreIds, genre.id]);
|
|
||||||
} else {
|
|
||||||
setEditGenreIds(editGenreIds.filter(id => id !== genre.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{getLocalizedValue(genre.name, activeTab)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.25rem' }}>
|
|
||||||
{specials.map(special => (
|
|
||||||
<label key={special.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#4b5563' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editSpecialIds.includes(special.id)}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setEditSpecialIds([...editSpecialIds, special.id]);
|
|
||||||
} else {
|
|
||||||
setEditSpecialIds(editSpecialIds.filter(id => id !== special.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{getLocalizedValue(special.name, activeTab)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editExcludeFromGlobal}
|
|
||||||
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Exclude from Global
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
|
||||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
|
||||||
{song.averageRating > 0 ? (
|
|
||||||
<span title={`${song.ratingCount} ratings`}>
|
|
||||||
{song.averageRating.toFixed(1)} ★ <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#ccc' }}>-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => saveEditing(song.id)}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title="Save"
|
|
||||||
>
|
|
||||||
✅
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelEditing}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title="Cancel"
|
|
||||||
>
|
|
||||||
❌
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
|
|
||||||
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
|
|
||||||
|
|
||||||
{song.excludeFromGlobal && (
|
|
||||||
<div style={{ marginTop: '0.25rem' }}>
|
|
||||||
<span style={{
|
|
||||||
background: '#fee2e2',
|
|
||||||
color: '#991b1b',
|
|
||||||
padding: '0.1rem 0.4rem',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
border: '1px solid #fecaca',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.25rem'
|
|
||||||
}}>
|
|
||||||
🚫 No Global
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Daily Puzzle Badges */}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
|
||||||
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
|
|
||||||
if (!p.genreId && !p.specialId) {
|
|
||||||
return (
|
|
||||||
<span key={p.id} style={{ background: '#dbeafe', color: '#1e40af', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #93c5fd' }}>
|
|
||||||
🌍 Global Daily
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (p.genreId) {
|
|
||||||
const genreName = genres.find(g => g.id === p.genreId)?.name;
|
|
||||||
return (
|
|
||||||
<span key={p.id} style={{ background: '#f3f4f6', color: '#374151', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #d1d5db' }}>
|
|
||||||
🏷️ {getLocalizedValue(genreName, activeTab)} Daily
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (p.specialId) {
|
|
||||||
const specialName = specials.find(s => s.id === p.specialId)?.name;
|
|
||||||
return (
|
|
||||||
<span key={p.id} style={{ background: '#fce7f3', color: '#be185d', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #fbcfe8' }}>
|
|
||||||
★ {getLocalizedValue(specialName, activeTab)} Daily
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
|
||||||
{song.releaseYear || '-'}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
|
||||||
{song.genres?.map(g => (
|
|
||||||
<span key={g.id} style={{
|
|
||||||
background: '#e5e7eb',
|
|
||||||
padding: '0.1rem 0.4rem',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
fontSize: '0.7rem'
|
|
||||||
}}>
|
|
||||||
{getLocalizedValue(g.name, activeTab)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
|
||||||
{song.specials?.map(s => (
|
|
||||||
<span key={s.id} style={{
|
|
||||||
background: '#fce7f3',
|
|
||||||
color: '#9d174d',
|
|
||||||
padding: '0.1rem 0.4rem',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
fontSize: '0.7rem'
|
|
||||||
}}>
|
|
||||||
{getLocalizedValue(s.name, activeTab)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
|
||||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
|
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
|
||||||
{song.averageRating > 0 ? (
|
|
||||||
<span title={`${song.ratingCount} ratings`}>
|
|
||||||
{song.averageRating.toFixed(1)} ★ <span style={{ color: '#999', fontSize: '0.8rem' }}>({song.ratingCount})</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#ccc' }}>-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => handlePlayPause(song)}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title={playingSongId === song.id ? "Pause" : "Play"}
|
|
||||||
>
|
|
||||||
{playingSongId === song.id ? '⏸️' : '▶️'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => startEditing(song)}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(song.id, song.title)}
|
|
||||||
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
|
|
||||||
title={t('deletePuzzle')}
|
|
||||||
>
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{paginatedSongs.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
|
||||||
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', alignItems: 'center' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
background: currentPage === 1 ? '#f3f4f6' : '#fff',
|
|
||||||
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span style={{ color: '#666' }}>
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
background: currentPage === totalPages ? '#f3f4f6' : '#fff',
|
|
||||||
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
|
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>
|
||||||
|
|||||||
10
app/[locale]/curator/page.tsx
Normal file
10
app/[locale]/curator/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorPageInner from '../../curator/page';
|
||||||
|
|
||||||
|
export default function CuratorPage() {
|
||||||
|
// Wrapper für die lokalisierte Route /[locale]/curator
|
||||||
|
return <CuratorPageInner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
42
app/api/curator/login/route.ts
Normal file
42
app/api/curator/login/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const curator = await prisma.curator.findUnique({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!curator) {
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(password, curator.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
curator: {
|
||||||
|
id: curator.id,
|
||||||
|
username: curator.username,
|
||||||
|
isGlobalCurator: curator.isGlobalCurator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Curator login error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
38
app/api/curator/me/route.ts
Normal file
38
app/api/curator/me/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) return error!;
|
||||||
|
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can access this endpoint' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [genres, specials] = await Promise.all([
|
||||||
|
prisma.curatorGenre.findMany({
|
||||||
|
where: { curatorId: context.curator.id },
|
||||||
|
select: { genreId: true },
|
||||||
|
}),
|
||||||
|
prisma.curatorSpecial.findMany({
|
||||||
|
where: { curatorId: context.curator.id },
|
||||||
|
select: { specialId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: context.curator.id,
|
||||||
|
username: context.curator.username,
|
||||||
|
isGlobalCurator: context.curator.isGlobalCurator,
|
||||||
|
genreIds: genres.map(g => g.genreId),
|
||||||
|
specialIds: specials.map(s => s.specialId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
182
app/api/curators/route.ts
Normal file
182
app/api/curators/route.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Only admin may list and manage curators
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const curators = await prisma.curator.findMany({
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
orderBy: { username: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
curators.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
username: c.username,
|
||||||
|
isGlobalCurator: c.isGlobalCurator,
|
||||||
|
genreIds: c.genres.map(g => g.genreId),
|
||||||
|
specialIds: c.specials.map(s => s.specialId),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { username, password, isGlobalCurator = false, genreIds = [], specialIds = [] } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const curator = await prisma.curator.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
isGlobalCurator: Boolean(isGlobalCurator),
|
||||||
|
genres: {
|
||||||
|
create: (genreIds as number[]).map(id => ({ genreId: id })),
|
||||||
|
},
|
||||||
|
specials: {
|
||||||
|
create: (specialIds as number[]).map(id => ({ specialId: id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: curator.id,
|
||||||
|
username: curator.username,
|
||||||
|
isGlobalCurator: curator.isGlobalCurator,
|
||||||
|
genreIds: curator.genres.map(g => g.genreId),
|
||||||
|
specialIds: curator.specials.map(s => s.specialId),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating curator:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { id, username, password, isGlobalCurator, genreIds, specialIds } = await request.json();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (username !== undefined) data.username = username;
|
||||||
|
if (isGlobalCurator !== undefined) data.isGlobalCurator = Boolean(isGlobalCurator);
|
||||||
|
if (password) {
|
||||||
|
data.passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
const curator = await tx.curator.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(genreIds)) {
|
||||||
|
await tx.curatorGenre.deleteMany({
|
||||||
|
where: { curatorId: curator.id },
|
||||||
|
});
|
||||||
|
if (genreIds.length > 0) {
|
||||||
|
await tx.curatorGenre.createMany({
|
||||||
|
data: (genreIds as number[]).map(gid => ({
|
||||||
|
curatorId: curator.id,
|
||||||
|
genreId: gid,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(specialIds)) {
|
||||||
|
await tx.curatorSpecial.deleteMany({
|
||||||
|
where: { curatorId: curator.id },
|
||||||
|
});
|
||||||
|
if (specialIds.length > 0) {
|
||||||
|
await tx.curatorSpecial.createMany({
|
||||||
|
data: (specialIds as number[]).map(sid => ({
|
||||||
|
curatorId: curator.id,
|
||||||
|
specialId: sid,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalCurator = await tx.curator.findUnique({
|
||||||
|
where: { id: curator.id },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finalCurator) {
|
||||||
|
throw new Error('Curator not found after update');
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalCurator;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: updated.id,
|
||||||
|
username: updated.username,
|
||||||
|
isGlobalCurator: updated.isGlobalCurator,
|
||||||
|
genreIds: updated.genres.map(g => g.genreId),
|
||||||
|
specialIds: updated.specials.map(s => s.specialId),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating curator:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const authError = await requireAdminAuth(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.curator.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting curator:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +1,60 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { writeFile, unlink } from 'fs/promises';
|
import { writeFile, unlink } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function getCuratorAssignments(curatorId: number) {
|
||||||
|
const [genres, specials] = await Promise.all([
|
||||||
|
prisma.curatorGenre.findMany({
|
||||||
|
where: { curatorId },
|
||||||
|
select: { genreId: true },
|
||||||
|
}),
|
||||||
|
prisma.curatorSpecial.findMany({
|
||||||
|
where: { curatorId },
|
||||||
|
select: { specialId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
genreIds: new Set(genres.map(g => g.genreId)),
|
||||||
|
specialIds: new Set(specials.map(s => s.specialId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||||
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
|
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||||
|
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id);
|
||||||
|
|
||||||
|
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
|
||||||
|
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
|
||||||
|
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
|
||||||
|
|
||||||
|
return hasGenre || hasSpecial;
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||||
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
|
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||||
|
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id);
|
||||||
|
|
||||||
|
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
|
||||||
|
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
|
||||||
|
|
||||||
|
return allGenresAllowed && allSpecialsAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
// Configure route to handle large file uploads
|
// Configure route to handle large file uploads
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||||
@@ -50,11 +97,11 @@ export async function GET() {
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
console.log('[UPLOAD] Starting song upload request');
|
console.log('[UPLOAD] Starting song upload request');
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) {
|
if (error || !context) {
|
||||||
console.log('[UPLOAD] Authentication failed');
|
console.log('[UPLOAD] Authentication failed');
|
||||||
return authError;
|
return error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -63,10 +110,17 @@ export async function POST(request: Request) {
|
|||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
let title = '';
|
let title = '';
|
||||||
let artist = '';
|
let artist = '';
|
||||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||||
|
|
||||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
|
||||||
|
|
||||||
|
// Apply global playlist rules:
|
||||||
|
// - Admin: may control the flag via form data
|
||||||
|
// - Curator: uploads are always excluded from global by default
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
excludeFromGlobal = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
console.error('[UPLOAD] No file provided');
|
console.error('[UPLOAD] No file provided');
|
||||||
@@ -261,9 +315,9 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||||
@@ -272,6 +326,69 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load current song with relations for permission checks
|
||||||
|
const existingSong = await prisma.song.findUnique({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSong) {
|
||||||
|
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let effectiveGenreIds = genreIds as number[] | undefined;
|
||||||
|
let effectiveSpecialIds = specialIds as number[] | undefined;
|
||||||
|
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
if (!curatorCanEditSong(context, existingSong, assignments)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden: You are not allowed to edit this song' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curators may assign genres, but only within their own assignments.
|
||||||
|
// Genres außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||||
|
if (effectiveGenreIds !== undefined) {
|
||||||
|
const invalidGenre = effectiveGenreIds.some(id => !assignments.genreIds.has(id));
|
||||||
|
if (invalidGenre) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only assign their own genres' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixedGenreIds = existingSong.genres
|
||||||
|
.filter(g => !assignments.genreIds.has(g.id))
|
||||||
|
.map(g => g.id);
|
||||||
|
const managedGenreIds = effectiveGenreIds.filter(id => assignments.genreIds.has(id));
|
||||||
|
effectiveGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curators may assign specials, but only within their own assignments.
|
||||||
|
// Specials außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||||
|
if (effectiveSpecialIds !== undefined) {
|
||||||
|
const invalidSpecial = effectiveSpecialIds.some(id => !assignments.specialIds.has(id));
|
||||||
|
if (invalidSpecial) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only assign their own specials' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSpecials = await prisma.specialSong.findMany({
|
||||||
|
where: { songId: Number(id) }
|
||||||
|
});
|
||||||
|
const fixedSpecialIds = currentSpecials
|
||||||
|
.map(ss => ss.specialId)
|
||||||
|
.filter(sid => !assignments.specialIds.has(sid));
|
||||||
|
const managedSpecialIds = effectiveSpecialIds.filter(id => assignments.specialIds.has(id));
|
||||||
|
effectiveSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data: any = { title, artist };
|
const data: any = { title, artist };
|
||||||
|
|
||||||
// Update releaseYear if provided (can be null to clear it)
|
// Update releaseYear if provided (can be null to clear it)
|
||||||
@@ -280,24 +397,35 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (excludeFromGlobal !== undefined) {
|
if (excludeFromGlobal !== undefined) {
|
||||||
data.excludeFromGlobal = excludeFromGlobal;
|
if (context.role === 'admin') {
|
||||||
|
data.excludeFromGlobal = excludeFromGlobal;
|
||||||
|
} else {
|
||||||
|
// Curators may only change the flag if they are global curators
|
||||||
|
if (!context.curator.isGlobalCurator) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
data.excludeFromGlobal = excludeFromGlobal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (genreIds) {
|
if (effectiveGenreIds && effectiveGenreIds.length > 0) {
|
||||||
data.genres = {
|
data.genres = {
|
||||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle SpecialSong relations separately
|
// Handle SpecialSong relations separately
|
||||||
if (specialIds !== undefined) {
|
if (effectiveSpecialIds !== undefined) {
|
||||||
// First, get current special assignments
|
// First, get current special assignments
|
||||||
const currentSpecials = await prisma.specialSong.findMany({
|
const currentSpecials = await prisma.specialSong.findMany({
|
||||||
where: { songId: Number(id) }
|
where: { songId: Number(id) }
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
const newSpecialIds = specialIds as number[];
|
const newSpecialIds = effectiveSpecialIds as number[];
|
||||||
|
|
||||||
// Delete removed specials
|
// Delete removed specials
|
||||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||||
@@ -344,9 +472,9 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
@@ -355,15 +483,30 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get song to find filename
|
// Get song to find filename and relations for permission checks
|
||||||
const song = await prisma.song.findUnique({
|
const song = await prisma.song.findUnique({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!song) {
|
if (!song) {
|
||||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
if (!curatorCanDeleteSong(context, song, assignments)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: You are not allowed to delete this song' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete file
|
// Delete file
|
||||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||||
try {
|
try {
|
||||||
|
|||||||
1300
app/curator/page.tsx
Normal file
1300
app/curator/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
lib/auth.ts
58
lib/auth.ts
@@ -1,4 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient, Curator } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export type StaffContext =
|
||||||
|
| { role: 'admin' }
|
||||||
|
| { role: 'curator'; curator: Curator };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication middleware for admin API routes
|
* Authentication middleware for admin API routes
|
||||||
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
|
|||||||
return null; // Auth successful
|
return null; // Auth successful
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve current staff (admin or curator) from headers.
|
||||||
|
*
|
||||||
|
* Admin:
|
||||||
|
* - x-admin-auth: 'authenticated'
|
||||||
|
*
|
||||||
|
* Curator:
|
||||||
|
* - x-curator-auth: 'authenticated'
|
||||||
|
* - x-curator-username: <username>
|
||||||
|
*/
|
||||||
|
export async function getStaffContext(request: NextRequest): Promise<StaffContext | null> {
|
||||||
|
const adminHeader = request.headers.get('x-admin-auth');
|
||||||
|
if (adminHeader === 'authenticated') {
|
||||||
|
return { role: 'admin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const curatorAuth = request.headers.get('x-curator-auth');
|
||||||
|
const curatorUsername = request.headers.get('x-curator-username');
|
||||||
|
|
||||||
|
if (curatorAuth === 'authenticated' && curatorUsername) {
|
||||||
|
const curator = await prisma.curator.findUnique({
|
||||||
|
where: { username: curatorUsername },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (curator) {
|
||||||
|
return { role: 'curator', curator };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require that the current request is authenticated as staff (admin or curator).
|
||||||
|
* Returns either an error response or a resolved context.
|
||||||
|
*/
|
||||||
|
export async function requireStaffAuth(request: NextRequest): Promise<{ error?: NextResponse; context?: StaffContext }> {
|
||||||
|
const context = await getStaffContext(request);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Unauthorized - Staff authentication required' },
|
||||||
|
{ status: 401 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { context };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to verify admin password
|
* Helper to verify admin password
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -157,7 +157,15 @@
|
|||||||
"artist": "Interpret",
|
"artist": "Interpret",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"deletePuzzle": "Löschen",
|
"deletePuzzle": "Löschen",
|
||||||
"wrongPassword": "Falsches Passwort"
|
"wrongPassword": "Falsches Passwort",
|
||||||
|
"manageCurators": "Kuratoren verwalten",
|
||||||
|
"addCurator": "Kurator hinzufügen",
|
||||||
|
"curatorUsername": "Benutzername",
|
||||||
|
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
|
||||||
|
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
|
||||||
|
"assignedGenres": "Zugeordnete Genres",
|
||||||
|
"assignedSpecials": "Zugeordnete Specials",
|
||||||
|
"noCurators": "Noch keine Kuratoren angelegt."
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "Über Hördle & Impressum",
|
"title": "Über Hördle & Impressum",
|
||||||
|
|||||||
@@ -157,7 +157,15 @@
|
|||||||
"artist": "Artist",
|
"artist": "Artist",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"deletePuzzle": "Delete",
|
"deletePuzzle": "Delete",
|
||||||
"wrongPassword": "Wrong password"
|
"wrongPassword": "Wrong password",
|
||||||
|
"manageCurators": "Manage Curators",
|
||||||
|
"addCurator": "Add Curator",
|
||||||
|
"curatorUsername": "Username",
|
||||||
|
"curatorPassword": "Password (leave empty to keep)",
|
||||||
|
"isGlobalCurator": "Global curator (may change global flag)",
|
||||||
|
"assignedGenres": "Assigned genres",
|
||||||
|
"assignedSpecials": "Assigned specials",
|
||||||
|
"noCurators": "No curators created yet."
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "About Hördle & Imprint",
|
"title": "About Hördle & Imprint",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Curator" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"isGlobalCurator" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CuratorGenre" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"curatorId" INTEGER NOT NULL,
|
||||||
|
"genreId" INTEGER NOT NULL,
|
||||||
|
CONSTRAINT "CuratorGenre_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CuratorGenre_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CuratorSpecial" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"curatorId" INTEGER NOT NULL,
|
||||||
|
"specialId" INTEGER NOT NULL,
|
||||||
|
CONSTRAINT "CuratorSpecial_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CuratorSpecial_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Curator_username_key" ON "Curator"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CuratorGenre_curatorId_genreId_key" ON "CuratorGenre"("curatorId", "genreId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CuratorSpecial_curatorId_specialId_key" ON "CuratorSpecial"("curatorId", "specialId");
|
||||||
@@ -33,6 +33,7 @@ model Genre {
|
|||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
|
curatorGenres CuratorGenre[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
@@ -48,6 +49,7 @@ model Special {
|
|||||||
songs SpecialSong[]
|
songs SpecialSong[]
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
news News[]
|
news News[]
|
||||||
|
curatorSpecials CuratorSpecial[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SpecialSong {
|
model SpecialSong {
|
||||||
@@ -102,6 +104,40 @@ model PlayerState {
|
|||||||
@@index([identifier])
|
@@index([identifier])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Curator {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
isGlobalCurator Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
genres CuratorGenre[]
|
||||||
|
specials CuratorSpecial[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorGenre {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
curatorId Int
|
||||||
|
genreId Int
|
||||||
|
|
||||||
|
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||||
|
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([curatorId, genreId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CuratorSpecial {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
curatorId Int
|
||||||
|
specialId Int
|
||||||
|
|
||||||
|
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||||
|
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([curatorId, specialId])
|
||||||
|
}
|
||||||
|
|
||||||
model PoliticalStatement {
|
model PoliticalStatement {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
locale String
|
locale String
|
||||||
|
|||||||
Reference in New Issue
Block a user