Compare commits

...

7 Commits

5 changed files with 91 additions and 10 deletions

View File

@@ -786,7 +786,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const handleSaveCurator = async (e: React.FormEvent) => { const handleSaveCurator = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!curatorUsername.trim()) return; if (!curatorUsername.trim()) {
alert('Bitte einen Benutzernamen eingeben.');
return;
}
// Beim Anlegen eines neuen Kurators ist ein Passwort Pflicht.
if (!editingCuratorId && !curatorPassword.trim()) {
alert('Für neue Kuratoren muss ein Passwort gesetzt werden.');
return;
}
const payload: any = { const payload: any = {
username: curatorUsername.trim(), username: curatorUsername.trim(),

View File

@@ -4,6 +4,7 @@ import CuratorPageInner from '../../curator/page';
export default function CuratorPage() { export default function CuratorPage() {
// Wrapper für die lokalisierte Route /[locale]/curator // Wrapper für die lokalisierte Route /[locale]/curator
// Hinweis: Pfad '../../curator/page' zeigt von 'app/[locale]/curator' korrekt auf 'app/curator/page'.
return <CuratorPageInner />; return <CuratorPageInner />;
} }

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client'; import { PrismaClient, Prisma } from '@prisma/client';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { requireAdminAuth } from '@/lib/auth'; import { requireAdminAuth } from '@/lib/auth';
@@ -69,6 +69,15 @@ export async function POST(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
console.error('Error creating curator:', error); console.error('Error creating curator:', error);
// Handle unique username constraint violation explicitly
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return NextResponse.json(
{ error: 'A curator with this username already exists.' },
{ status: 409 }
);
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
} }
} }
@@ -153,6 +162,15 @@ export async function PUT(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
console.error('Error updating curator:', error); console.error('Error updating curator:', error);
// Handle unique username constraint violation explicitly for updates
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return NextResponse.json(
{ error: 'A curator with this username already exists.' },
{ status: 409 }
);
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
} }
} }

View File

@@ -30,7 +30,19 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
if (context.role === 'admin') return true; if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id); const songGenreIds = (song.genres || []).map((g: any) => g.id);
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id); // `song.specials` kann je nach Context entweder ein Array von
// - `Special` (mit `id`)
// - `SpecialSong` (mit `specialId`)
// - `SpecialSong` (mit Relation `special.id`)
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.id != null) return s.id;
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
return undefined;
})
.filter((id: any): id is number => typeof id === 'number');
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar // Songs ohne Genres/Specials sind für Kuratoren generell editierbar
if (songGenreIds.length === 0 && songSpecialIds.length === 0) { if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
@@ -47,7 +59,14 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
if (context.role === 'admin') return true; if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id); const songGenreIds = (song.genres || []).map((g: any) => g.id);
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id); const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.id != null) return s.id;
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
return undefined;
})
.filter((id: any): id is number => typeof id === 'number');
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id)); const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id)); const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
@@ -59,7 +78,11 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
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
export async function GET() { export async function GET(request: NextRequest) {
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
const songs = await prisma.song.findMany({ const songs = await prisma.song.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
@@ -73,8 +96,31 @@ export async function GET() {
}, },
}); });
let visibleSongs = songs;
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
visibleSongs = songs.filter(song => {
const songGenreIds = song.genres.map(g => g.id);
// `song.specials` ist hier ein Array von SpecialSong mit Relation `special`,
// wir nutzen konsistent die Special-ID.
const songSpecialIds = song.specials.map(ss => ss.special.id);
// Songs ohne Genres/Specials sind immer sichtbar
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
return true;
}
const hasGenre = songGenreIds.some(id => assignments.genreIds.has(id));
const hasSpecial = songSpecialIds.some(id => assignments.specialIds.has(id));
return hasGenre || hasSpecial;
});
}
// Map to include activation count and flatten specials // Map to include activation count and flatten specials
const songsWithActivations = songs.map(song => ({ const songsWithActivations = visibleSongs.map(song => ({
id: song.id, id: song.id,
title: song.title, title: song.title,
artist: song.artist, artist: song.artist,
@@ -411,7 +457,8 @@ export async function PUT(request: Request) {
} }
} }
if (effectiveGenreIds && effectiveGenreIds.length > 0) { // Wenn effectiveGenreIds definiert ist, auch leere Arrays übernehmen (löscht alle Zuordnungen).
if (effectiveGenreIds !== undefined) {
data.genres = { data.genres = {
set: effectiveGenreIds.map((gId: number) => ({ id: gId })) set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
}; };

View File

@@ -332,6 +332,12 @@ export default function CuratorPage() {
setPlayingSongId(null); setPlayingSongId(null);
setAudioElement(null); setAudioElement(null);
}); });
// Reset Zustand, wenn der Track zu Ende gespielt ist
audio.onended = () => {
setPlayingSongId(null);
setAudioElement(null);
};
} }
}; };
@@ -731,7 +737,7 @@ export default function CuratorPage() {
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignGenresLabel')}</div> <div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignGenresLabel')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres {genres
.filter(g => curatorInfo?.genreIds.includes(g.id)) .filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => ( .map(genre => (
<label <label
key={genre.id} key={genre.id}
@@ -846,7 +852,7 @@ export default function CuratorPage() {
<option value="no-global">{t('filterNoGlobal')}</option> <option value="no-global">{t('filterNoGlobal')}</option>
<optgroup label="Genres"> <optgroup label="Genres">
{genres {genres
.filter(g => curatorInfo?.genreIds.includes(g.id)) .filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => ( .map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}> <option key={genre.id} value={`genre:${genre.id}`}>
{typeof genre.name === 'string' {typeof genre.name === 'string'
@@ -857,7 +863,7 @@ export default function CuratorPage() {
</optgroup> </optgroup>
<optgroup label="Specials"> <optgroup label="Specials">
{specials {specials
.filter(s => curatorInfo?.specialIds.includes(s.id)) .filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => ( .map(special => (
<option key={special.id} value={`special:${special.id}`}> <option key={special.id} value={`special:${special.id}`}>
{' '} {' '}