From 38148ace8d5f80ab39bb44b6959e1f35373bc021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Wed, 3 Dec 2025 12:52:38 +0100 Subject: [PATCH] Kuratoren-Accounts und Anpassungen im Admin- und Kuratoren-Dashboard --- app/[locale]/admin/page.tsx | 886 ++++------- app/[locale]/curator/page.tsx | 10 + app/api/curator/login/route.ts | 42 + app/api/curator/me/route.ts | 38 + app/api/curators/route.ts | 182 +++ app/api/songs/route.ts | 183 ++- app/curator/page.tsx | 1300 +++++++++++++++++ lib/auth.ts | 58 + messages/de.json | 10 +- messages/en.json | 10 +- .../migration.sql | 36 + prisma/schema.prisma | 36 + 12 files changed, 2171 insertions(+), 620 deletions(-) create mode 100644 app/[locale]/curator/page.tsx create mode 100644 app/api/curator/login/route.ts create mode 100644 app/api/curator/me/route.ts create mode 100644 app/api/curators/route.ts create mode 100644 app/curator/page.tsx create mode 100644 prisma/migrations/20251203101200_kuratoren_accounts/migration.sql diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx index ba3f881..b80f72d 100644 --- a/app/[locale]/admin/page.tsx +++ b/app/[locale]/admin/page.tsx @@ -76,6 +76,14 @@ interface PoliticalStatement { locale: string; } +interface Curator { + id: number; + username: string; + isGlobalCurator: boolean; + genreIds: number[]; + specialIds: number[]; +} + type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating'; type SortDirection = 'asc' | 'desc'; @@ -159,14 +167,18 @@ export default function AdminPage({ params }: { params: { locale: string } }) { const [sortField, setSortField] = useState('artist'); const [sortDirection, setSortDirection] = useState('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 [selectedGenreFilter, setSelectedGenreFilter] = useState(''); const [selectedSpecialFilter, setSelectedSpecialFilter] = useState(null); const [currentPage, setCurrentPage] = useState(1); 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(null); const [audioElement, setAudioElement] = useState(null); @@ -184,6 +196,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) { const [newPoliticalStatementActive, setNewPoliticalStatementActive] = useState(true); const fileInputRef = useRef(null); + // Curators state + const [curators, setCurators] = useState([]); + const [showCurators, setShowCurators] = useState(false); + const [editingCuratorId, setEditingCuratorId] = useState(null); + const [curatorUsername, setCuratorUsername] = useState(''); + const [curatorPassword, setCuratorPassword] = useState(''); + const [curatorIsGlobal, setCuratorIsGlobal] = useState(false); + const [curatorGenreIds, setCuratorGenreIds] = useState([]); + const [curatorSpecialIds, setCuratorSpecialIds] = useState([]); + // Check for existing auth on mount useEffect(() => { const authToken = localStorage.getItem('hoerdle_admin_auth'); @@ -194,6 +216,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) { fetchDailyPuzzles(); fetchSpecials(); fetchNews(); + fetchCurators(); } }, []); @@ -210,6 +233,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) { fetchDailyPuzzles(); fetchSpecials(); fetchNews(); + fetchCurators(); } else { alert(t('wrongPassword')); } @@ -224,6 +248,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) { setGenres([]); setSpecials([]); setDailyPuzzles([]); + setCurators([]); }; // 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 res = await fetch('/api/genres', { 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) => { e.preventDefault(); 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) => { if (playingSongId === song.id) { // Pause current song @@ -1067,70 +1172,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) { } }; - // Filter and sort songs - 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]); + // Song Library ist in das Kuratoren-Dashboard umgezogen, daher keine Song-Filter/Pagination mehr im Admin nötig. if (!isAuthenticated) { return ( @@ -1826,155 +1868,193 @@ export default function AdminPage({ params }: { params: { locale: string } }) { )} + {/* Curator Management */}
-

{t('uploadSongs')}

-
- {/* Drag & Drop Zone */} -
+

+ {t('manageCurators')} +

+
- - {/* File List */} - {files.length > 0 && ( -
-

Selected Files:

-
- {files.map((file, index) => ( -
- 📄 {file.name} + {showCurators ? t('hide') : t('show')} + +
+ {showCurators && ( + <> + +
+
+ setCuratorUsername(e.target.value)} + placeholder={t('curatorUsername')} + className="form-input" + style={{ minWidth: '200px', flex: '1 1 200px' }} + required + /> + setCuratorPassword(e.target.value)} + placeholder={t('curatorPassword')} + className="form-input" + style={{ minWidth: '200px', flex: '1 1 200px' }} + /> + +
+
+
+
{t('assignedGenres')}
+
+ {genres.map(genre => ( + + ))} +
- ))} +
+
{t('assignedSpecials')}
+
+ {specials.map(special => ( + + ))} +
+
+
+
+ + {editingCuratorId && ( + + )} +
-
- )} + - {/* Upload Progress */} - {isUploading && ( -
-

- Uploading: {uploadProgress.current} / {uploadProgress.total} -

-
-
-
-
- )} - -
- -
- {genres.map(genre => ( -
-

- Selected genres will be assigned to all uploaded songs. -

-
- -
- -

- If checked, these songs will only appear in Genre or Special puzzles. -

-
- - - - {message && ( -
- {message} -
- )} - + + )}
+ {/* Upload Songs wurde in das Kuratoren-Dashboard verlagert */} + {/* Today's Daily Puzzles */}
@@ -2049,397 +2129,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) { ))}
-
-

- Song Library ({songs.length} songs) -

- - {/* Search and Filter */} -
- setSearchQuery(e.target.value)} - className="form-input" - style={{ flex: '1', minWidth: '200px' }} - /> - - {(searchQuery || selectedGenreFilter) && ( - - )} -
- -
- - - - - - - - - - - - - - - {paginatedSongs.map(song => ( - - - - {editingId === song.id ? ( - <> - - - - - - - - - ) : ( - <> - - - - - - - - - )} - - ))} - {paginatedSongs.length === 0 && ( - - - - )} - -
handleSort('id')} - > - ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')} - handleSort('title')} - > - Song {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')} - handleSort('releaseYear')} - > - Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')} - Genres / Specials handleSort('createdAt')} - > - Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')} - handleSort('activations')} - > - Activations {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')} - handleSort('averageRating')} - > - Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')} - Actions
{song.id} - setEditTitle(e.target.value)} - className="form-input" - style={{ padding: '0.25rem', marginBottom: '0.5rem', width: '100%' }} - placeholder="Title" - /> - setEditArtist(e.target.value)} - className="form-input" - style={{ padding: '0.25rem', width: '100%' }} - placeholder="Artist" - /> - - setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))} - className="form-input" - style={{ padding: '0.25rem', width: '80px' }} - placeholder="Year" - /> - -
- {genres.map(genre => ( - - ))} -
-
- {specials.map(special => ( - - ))} -
-
- -
-
- {new Date(song.createdAt).toLocaleDateString('de-DE')} - {song.activations} - {song.averageRating > 0 ? ( - - {song.averageRating.toFixed(1)} ★ ({song.ratingCount}) - - ) : ( - - - )} - -
- - -
-
-
{song.title}
-
{song.artist}
- - {song.excludeFromGlobal && ( -
- - 🚫 No Global - -
- )} - - {/* Daily Puzzle Badges */} -
- {song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => { - if (!p.genreId && !p.specialId) { - return ( - - 🌍 Global Daily - - ); - } - if (p.genreId) { - const genreName = genres.find(g => g.id === p.genreId)?.name; - return ( - - 🏷️ {getLocalizedValue(genreName, activeTab)} Daily - - ); - } - if (p.specialId) { - const specialName = specials.find(s => s.id === p.specialId)?.name; - return ( - - ★ {getLocalizedValue(specialName, activeTab)} Daily - - ); - } - return null; - })} -
-
- {song.releaseYear || '-'} - -
- {song.genres?.map(g => ( - - {getLocalizedValue(g.name, activeTab)} - - ))} -
-
- {song.specials?.map(s => ( - - {getLocalizedValue(s.name, activeTab)} - - ))} -
-
- {new Date(song.createdAt).toLocaleDateString('de-DE')} - {song.activations} - {song.averageRating > 0 ? ( - - {song.averageRating.toFixed(1)} ★ ({song.ratingCount}) - - ) : ( - - - )} - -
- - - -
-
- {searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'} -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {currentPage} of {totalPages} - - -
- )} -
+ {/* Song Library wurde in das Kuratoren-Dashboard verlagert */}

diff --git a/app/[locale]/curator/page.tsx b/app/[locale]/curator/page.tsx new file mode 100644 index 0000000..90dc4b1 --- /dev/null +++ b/app/[locale]/curator/page.tsx @@ -0,0 +1,10 @@ +'use client'; + +import CuratorPageInner from '../../curator/page'; + +export default function CuratorPage() { + // Wrapper für die lokalisierte Route /[locale]/curator + return ; +} + + diff --git a/app/api/curator/login/route.ts b/app/api/curator/login/route.ts new file mode 100644 index 0000000..3ada2c0 --- /dev/null +++ b/app/api/curator/login/route.ts @@ -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 }); + } +} + + diff --git a/app/api/curator/me/route.ts b/app/api/curator/me/route.ts new file mode 100644 index 0000000..8ce275b --- /dev/null +++ b/app/api/curator/me/route.ts @@ -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), + }); +} + + diff --git a/app/api/curators/route.ts b/app/api/curators/route.ts new file mode 100644 index 0000000..aff08ec --- /dev/null +++ b/app/api/curators/route.ts @@ -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 }); + } +} + + diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts index 2bd4900..6b16708 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -1,13 +1,60 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; import { writeFile, unlink } from 'fs/promises'; import path from 'path'; import { parseBuffer } from 'music-metadata'; import { isDuplicateSong } from '@/lib/fuzzyMatch'; -import { requireAdminAuth } from '@/lib/auth'; +import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth'; 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; specialIds: Set }) { + 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; specialIds: Set }) { + 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 export const runtime = 'nodejs'; export const maxDuration = 60; // 60 seconds timeout for uploads @@ -50,11 +97,11 @@ export async function GET() { export async function POST(request: Request) { console.log('[UPLOAD] Starting song upload request'); - // Check authentication - const authError = await requireAdminAuth(request as any); - if (authError) { + // Check authentication (admin or curator) + const { error, context } = await requireStaffAuth(request as unknown as NextRequest); + if (error || !context) { console.log('[UPLOAD] Authentication failed'); - return authError; + return error!; } try { @@ -63,10 +110,17 @@ export async function POST(request: Request) { const file = formData.get('file') as File; let title = ''; 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] 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) { console.error('[UPLOAD] No file provided'); @@ -261,9 +315,9 @@ export async function POST(request: Request) { } export async function PUT(request: Request) { - // Check authentication - const authError = await requireAdminAuth(request as any); - if (authError) return authError; + // Check authentication (admin or curator) + const { error, context } = await requireStaffAuth(request as unknown as NextRequest); + if (error || !context) return error!; try { 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 }); } + // 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 }; // Update releaseYear if provided (can be null to clear it) @@ -280,24 +397,35 @@ export async function PUT(request: Request) { } 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 = { - set: genreIds.map((gId: number) => ({ id: gId })) + set: effectiveGenreIds.map((gId: number) => ({ id: gId })) }; } // Handle SpecialSong relations separately - if (specialIds !== undefined) { + if (effectiveSpecialIds !== undefined) { // First, get current special assignments const currentSpecials = await prisma.specialSong.findMany({ where: { songId: Number(id) } }); const currentSpecialIds = currentSpecials.map(ss => ss.specialId); - const newSpecialIds = specialIds as number[]; + const newSpecialIds = effectiveSpecialIds as number[]; // Delete removed specials const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid)); @@ -344,9 +472,9 @@ export async function PUT(request: Request) { } export async function DELETE(request: Request) { - // Check authentication - const authError = await requireAdminAuth(request as any); - if (authError) return authError; + // Check authentication (admin or curator) + const { error, context } = await requireStaffAuth(request as unknown as NextRequest); + if (error || !context) return error!; try { const { id } = await request.json(); @@ -355,15 +483,30 @@ export async function DELETE(request: Request) { 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({ where: { id: Number(id) }, + include: { + genres: true, + specials: true, + }, }); if (!song) { 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 const filePath = path.join(process.cwd(), 'public/uploads', song.filename); try { diff --git a/app/curator/page.tsx b/app/curator/page.tsx new file mode 100644 index 0000000..07186a7 --- /dev/null +++ b/app/curator/page.tsx @@ -0,0 +1,1300 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +interface Genre { + id: number; + name: any; +} + +interface Special { + id: number; + name: any; +} + +interface Song { + id: number; + title: string; + artist: string; + filename: string; + createdAt: string; + releaseYear: number | null; + activations?: number; + puzzles?: any[]; + genres: Genre[]; + specials: Special[]; + excludeFromGlobal: boolean; + averageRating?: number; + ratingCount?: number; +} + +interface CuratorInfo { + id: number; + username: string; + isGlobalCurator: boolean; + genreIds: number[]; + specialIds: number[]; +} + +function getCuratorAuthHeaders() { + const authToken = localStorage.getItem('hoerdle_curator_auth'); + const username = localStorage.getItem('hoerdle_curator_username') || ''; + return { + 'Content-Type': 'application/json', + 'x-curator-auth': authToken || '', + 'x-curator-username': username, + }; +} + +function getCuratorUploadHeaders() { + const authToken = localStorage.getItem('hoerdle_curator_auth'); + const username = localStorage.getItem('hoerdle_curator_username') || ''; + return { + 'x-curator-auth': authToken || '', + 'x-curator-username': username, + }; +} + +export default function CuratorPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [curatorInfo, setCuratorInfo] = useState(null); + const [songs, setSongs] = useState([]); + const [genres, setGenres] = useState([]); + const [specials, setSpecials] = useState([]); + const [loading, setLoading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + const [editArtist, setEditArtist] = useState(''); + const [editReleaseYear, setEditReleaseYear] = useState(''); + const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false); + const [editGenreIds, setEditGenreIds] = useState([]); + const [editSpecialIds, setEditSpecialIds] = useState([]); + const [message, setMessage] = useState(''); + + // Upload state (analog zum Admin-Upload, aber vereinfacht) + const [files, setFiles] = useState([]); + const [uploadGenreIds, setUploadGenreIds] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number }>({ + current: 0, + total: 0, + }); + const [uploadResults, setUploadResults] = useState([]); + const fileInputRef = useRef(null); + + // Search / Sort / Pagination / Audio (ähnlich Admin-Song-Library) + type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating'; + type SortDirection = 'asc' | 'desc'; + const [sortField, setSortField] = useState('artist'); + const [sortDirection, setSortDirection] = useState('asc'); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedFilter, setSelectedFilter] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + const [playingSongId, setPlayingSongId] = useState(null); + const [audioElement, setAudioElement] = useState(null); + + useEffect(() => { + const authToken = localStorage.getItem('hoerdle_curator_auth'); + const storedUsername = localStorage.getItem('hoerdle_curator_username'); + if (authToken === 'authenticated' && storedUsername) { + setIsAuthenticated(true); + setUsername(storedUsername); + bootstrapCuratorData(); + } + }, []); + + const bootstrapCuratorData = async () => { + try { + setLoading(true); + await Promise.all([fetchCuratorInfo(), fetchSongs(), fetchGenres(), fetchSpecials()]); + } finally { + setLoading(false); + } + }; + + const fetchCuratorInfo = async () => { + const res = await fetch('/api/curator/me', { + headers: getCuratorAuthHeaders(), + }); + if (res.ok) { + const data: CuratorInfo = await res.json(); + setCuratorInfo(data); + localStorage.setItem('hoerdle_curator_is_global', String(data.isGlobalCurator)); + } else { + setMessage('Fehler beim Laden der Kuratoren-Informationen.'); + } + }; + + const fetchSongs = async () => { + const res = await fetch('/api/songs', { + headers: getCuratorAuthHeaders(), + }); + if (res.ok) { + const data: Song[] = await res.json(); + setSongs(data); + } else { + setMessage('Fehler beim Laden der Songs.'); + } + }; + + const fetchGenres = async () => { + const res = await fetch('/api/genres'); + if (res.ok) { + const data = await res.json(); + setGenres(data); + } + }; + + const fetchSpecials = async () => { + const res = await fetch('/api/specials'); + if (res.ok) { + const data = await res.json(); + setSpecials(data); + } + }; + + const handleLogin = async () => { + setMessage(''); + try { + const res = await fetch('/api/curator/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + if (res.ok) { + const data = await res.json(); + localStorage.setItem('hoerdle_curator_auth', 'authenticated'); + localStorage.setItem('hoerdle_curator_username', data.curator.username); + localStorage.setItem('hoerdle_curator_is_global', String(data.curator.isGlobalCurator)); + setIsAuthenticated(true); + setPassword(''); + await bootstrapCuratorData(); + } else { + const err = await res.json().catch(() => null); + setMessage(err?.error || 'Login fehlgeschlagen.'); + } + } catch (e) { + setMessage('Netzwerkfehler beim Login.'); + } + }; + + const handleLogout = () => { + localStorage.removeItem('hoerdle_curator_auth'); + localStorage.removeItem('hoerdle_curator_username'); + localStorage.removeItem('hoerdle_curator_is_global'); + setIsAuthenticated(false); + setCuratorInfo(null); + setSongs([]); + setMessage(''); + }; + + const startEditing = (song: Song) => { + setEditingId(song.id); + setEditTitle(song.title); + setEditArtist(song.artist); + setEditReleaseYear(song.releaseYear || ''); + setEditExcludeFromGlobal(song.excludeFromGlobal || false); + setEditGenreIds(song.genres.map(g => g.id)); + setEditSpecialIds(song.specials.map(s => s.id)); + }; + + const cancelEditing = () => { + setEditingId(null); + setEditTitle(''); + setEditArtist(''); + setEditReleaseYear(''); + setEditExcludeFromGlobal(false); + setEditGenreIds([]); + setEditSpecialIds([]); + }; + + const saveEditing = async (id: number) => { + if (!curatorInfo) return; + setMessage(''); + const isGlobalCurator = curatorInfo.isGlobalCurator; + + // Nur Genres/Specials, für die der Kurator zuständig ist, dürfen aktiv geändert werden. + const manageableGenreIds = editGenreIds.filter(gid => curatorInfo.genreIds.includes(gid)); + const manageableSpecialIds = editSpecialIds.filter(sid => curatorInfo.specialIds.includes(sid)); + + try { + const res = await fetch('/api/songs', { + method: 'PUT', + headers: getCuratorAuthHeaders(), + body: JSON.stringify({ + id, + title: editTitle, + artist: editArtist, + releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear), + genreIds: manageableGenreIds, + specialIds: manageableSpecialIds, + excludeFromGlobal: isGlobalCurator ? editExcludeFromGlobal : undefined, + }), + }); + + if (res.ok) { + setEditingId(null); + await fetchSongs(); + setMessage('Song erfolgreich aktualisiert.'); + } else { + const errText = await res.text(); + setMessage(`Fehler beim Speichern: ${errText}`); + } + } catch (e) { + setMessage('Netzwerkfehler beim Speichern.'); + } + }; + + const canEditSong = (song: Song): boolean => { + if (!curatorInfo) return false; + const songGenreIds = song.genres.map(g => g.id); + const songSpecialIds = song.specials.map(s => s.id); + if (songGenreIds.length === 0 && songSpecialIds.length === 0) { + // Songs ohne Genres/Specials dürfen von Kuratoren generell bearbeitet werden + return true; + } + const hasGenre = songGenreIds.some(id => curatorInfo.genreIds.includes(id)); + const hasSpecial = songSpecialIds.some(id => curatorInfo.specialIds.includes(id)); + return hasGenre || hasSpecial; + }; + + const canDeleteSong = (song: Song): boolean => { + if (!curatorInfo) return false; + const songGenreIds = song.genres.map(g => g.id); + const songSpecialIds = song.specials.map(s => s.id); + const allGenresAllowed = songGenreIds.every(id => curatorInfo.genreIds.includes(id)); + const allSpecialsAllowed = songSpecialIds.every(id => curatorInfo.specialIds.includes(id)); + return allGenresAllowed && allSpecialsAllowed; + }; + + const handleDelete = async (song: Song) => { + if (!canDeleteSong(song)) { + setMessage('Du darfst diesen Song nicht löschen.'); + return; + } + if (!confirm(`Möchtest du "${song.title}" wirklich löschen?`)) return; + + try { + const res = await fetch('/api/songs', { + method: 'DELETE', + headers: getCuratorAuthHeaders(), + body: JSON.stringify({ id: song.id }), + }); + if (res.ok) { + await fetchSongs(); + setMessage('Song gelöscht.'); + } else { + const errText = await res.text(); + setMessage(`Fehler beim Löschen: ${errText}`); + } + } catch (e) { + setMessage('Netzwerkfehler beim Löschen.'); + } + }; + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDirection('asc'); + } + }; + + const handlePlayPause = (song: Song) => { + if (playingSongId === song.id) { + audioElement?.pause(); + setPlayingSongId(null); + } else { + audioElement?.pause(); + + const audio = new Audio(`/api/audio/${song.filename}`); + audio.onerror = () => { + setPlayingSongId(null); + setAudioElement(null); + alert(`Audio-Datei konnte nicht geladen werden: ${song.filename}`); + }; + + audio.play() + .then(() => { + setAudioElement(audio); + setPlayingSongId(song.id); + }) + .catch(error => { + console.error('Playback error:', error); + setPlayingSongId(null); + setAudioElement(null); + }); + } + }; + + const toggleUploadGenre = (genreId: number) => { + setUploadGenreIds(prev => + prev.includes(genreId) ? prev.filter(id => id !== genreId) : [...prev, genreId] + ); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files || []); + if (selected.length === 0) return; + setFiles(prev => [...prev, ...selected]); + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!isDragging) { + setIsDragging(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const droppedFiles = Array.from(e.dataTransfer.files || []).filter( + f => f.type === 'audio/mpeg' || f.name.toLowerCase().endsWith('.mp3') + ); + if (droppedFiles.length === 0) return; + setFiles(prev => [...prev, ...droppedFiles]); + }; + + const handleBatchUpload = async (e: React.FormEvent) => { + e.preventDefault(); + if (files.length === 0) return; + + setIsUploading(true); + setUploadResults([]); + setUploadProgress({ current: 0, total: files.length }); + setMessage(''); + + const results: any[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + setUploadProgress({ current: i + 1, total: files.length }); + + try { + const formData = new FormData(); + formData.append('file', file); + // excludeFromGlobal wird für Kuratoren serverseitig immer auf true gesetzt + + const res = await fetch('/api/songs', { + method: 'POST', + headers: getCuratorUploadHeaders(), + body: formData, + }); + + if (res.ok) { + const data = await res.json(); + results.push({ + filename: file.name, + success: true, + song: data.song, + validation: data.validation, + }); + } else if (res.status === 409) { + const data = await res.json(); + results.push({ + filename: file.name, + success: false, + isDuplicate: true, + duplicate: data.duplicate, + error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`, + }); + } else { + const errorText = await res.text(); + results.push({ + filename: file.name, + success: false, + error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`, + }); + } + } catch (error) { + results.push({ + filename: file.name, + success: false, + error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } + } + + setUploadResults(results); + setFiles([]); + setIsUploading(false); + + // Genres den erfolgreich hochgeladenen Songs zuweisen + if (uploadGenreIds.length > 0) { + const successfulUploads = results.filter(r => r.success && r.song); + for (const result of successfulUploads) { + try { + await fetch('/api/songs', { + method: 'PUT', + headers: getCuratorAuthHeaders(), + body: JSON.stringify({ + id: result.song.id, + title: result.song.title, + artist: result.song.artist, + releaseYear: result.song.releaseYear, + genreIds: uploadGenreIds, + }), + }); + } catch { + // Fehler beim Genre-Assigning werden nur geloggt, nicht abgebrochen + console.error(`Failed to assign genres to ${result.song.title}`); + } + } + } + + await fetchSongs(); + + const successCount = results.filter(r => r.success).length; + const duplicateCount = results.filter(r => r.isDuplicate).length; + const failedCount = results.filter(r => !r.success && !r.isDuplicate).length; + + let msg = `✅ ${successCount}/${results.length} Uploads erfolgreich.`; + if (duplicateCount > 0) msg += `\n⚠️ ${duplicateCount} Duplikat(e) übersprungen.`; + if (failedCount > 0) msg += `\n❌ ${failedCount} fehlgeschlagen.`; + setMessage(msg); + }; + + if (!isAuthenticated) { + return ( +
+

Kuratoren-Login

+
+ + + + {message && ( +

{message}

+ )} +
+
+ ); + } + + // Filter, Sort & Pagination basierend auf Admin-Logik, aber auf Kuratoren-Rechte zugeschnitten + const filteredSongs = songs.filter(song => { + // Nur Songs anzeigen, die für den Kurator relevant sind + if (curatorInfo && !canEditSong(song) && !canDeleteSong(song)) { + return false; + } + + // Filter nach Global/Genre/Special + if (selectedFilter) { + if (selectedFilter === 'no-global') { + if (!song.excludeFromGlobal) return false; + } else if (selectedFilter.startsWith('genre:')) { + const genreId = Number(selectedFilter.split(':')[1]); + if (!song.genres.some(g => g.id === genreId)) return false; + } else if (selectedFilter.startsWith('special:')) { + const specialId = Number(selectedFilter.split(':')[1]); + if (!song.specials.some(s => s.id === specialId)) return false; + } + } + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + if ( + !song.title.toLowerCase().includes(q) && + !song.artist.toLowerCase().includes(q) + ) { + return false; + } + } + + return true; + }); + + const sortedSongs = [...filteredSongs].sort((a, b) => { + const dir = sortDirection === 'asc' ? 1 : -1; + switch (sortField) { + case 'id': + return (a.id - b.id) * dir; + case 'title': + return a.title.localeCompare(b.title) * dir; + case 'artist': + return a.artist.localeCompare(b.artist) * dir; + case 'createdAt': + return (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) * dir; + case 'releaseYear': + return ((a.releaseYear || 0) - (b.releaseYear || 0)) * dir; + case 'activations': { + const av = a.activations ?? a.puzzles?.length ?? 0; + const bv = b.activations ?? b.puzzles?.length ?? 0; + return (av - bv) * dir; + } + case 'averageRating': + return ((a.averageRating || 0) - (b.averageRating || 0)) * dir; + default: + return 0; + } + }); + + const totalPages = Math.max(1, Math.ceil(sortedSongs.length / itemsPerPage)); + const page = Math.min(currentPage, totalPages); + const startIndex = (page - 1) * itemsPerPage; + const visibleSongs = sortedSongs.slice(startIndex, startIndex + itemsPerPage); + + return ( +
+
+
+

Kuratoren-Dashboard

+ {curatorInfo && ( +

+ Eingeloggt als {curatorInfo.username} + {curatorInfo.isGlobalCurator && ' (Globaler Kurator)'} +

+ )} +
+ +
+ + {loading &&

Lade Daten...

} + {message && ( +

{message}

+ )} + +
+

Titel hochladen

+

+ Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert + (inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens + eines deiner Genres aus, um die Titel zuzuordnen. +

+
+
fileInputRef.current?.click()} + > +
📁
+

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

+

oder klicken, um Dateien auszuwählen

+ +
+ + {files.length > 0 && ( +
+

Ausgewählte Dateien:

+
+ {files.map((file, index) => ( +
+ 📄 {file.name} +
+ ))} +
+
+ )} + + {isUploading && ( +
+

+ Upload: {uploadProgress.current} / {uploadProgress.total} +

+
+
0 + ? `${(uploadProgress.current / uploadProgress.total) * 100}%` + : '0%', + height: '100%', + background: '#4f46e5', + transition: 'width 0.3s', + }} + /> +
+
+ )} + +
+
Genres zuordnen
+
+ {genres + .filter(g => curatorInfo?.genreIds.includes(g.id)) + .map(genre => ( + + ))} + {curatorInfo && curatorInfo.genreIds.length === 0 && ( + + Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin. + + )} +
+
+ + + + {uploadResults.length > 0 && ( +
+ {uploadResults.map((r, idx) => ( +
+ {r.filename} –{' '} + {r.success + ? '✅ erfolgreich' + : r.isDuplicate + ? `⚠️ Duplikat: ${r.error}` + : `❌ Fehler: ${r.error}`} +
+ ))} +
+ )} + +
+ +
+

+ Titel in deinen Genres & Specials ({filteredSongs.length} Titel) +

+

+ Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. + Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist. + Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden. +

+ + {/* Suche & Filter */} +
+ { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + style={{ + flex: '1', + minWidth: '200px', + padding: '0.4rem 0.6rem', + borderRadius: '0.25rem', + border: '1px solid #d1d5db', + }} + /> + + {(searchQuery || selectedFilter) && ( + + )} +
+ + {visibleSongs.length === 0 ? ( +

Keine passenden Songs in deinen Genres/Specials gefunden.

+ ) : ( + <> +
+ + + + + + + + + + + + + + + + + + {visibleSongs.map(song => { + const editable = canEditSong(song); + const deletable = canDeleteSong(song); + const isEditing = editingId === song.id; + const ratingText = + song.ratingCount && song.ratingCount > 0 + ? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})` + : '-'; + + return ( + + + + + + + + + + + + + + ); + })} + +
handleSort('id')} + > + ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')} + Play handleSort('title')} + > + Titel {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')} + handleSort('artist')} + > + Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')} + handleSort('releaseYear')} + > + Jahr {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')} + Genres / Specials handleSort('createdAt')} + > + Hinzugefügt {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')} + handleSort('activations')} + > + Aktivierungen {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')} + handleSort('averageRating')} + > + Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')} + Exclude GlobalAktionen
{song.id} + + + {isEditing ? ( + setEditTitle(e.target.value)} + style={{ width: '100%', padding: '0.25rem' }} + /> + ) : ( + song.title + )} + + {isEditing ? ( + setEditArtist(e.target.value)} + style={{ width: '100%', padding: '0.25rem' }} + /> + ) : ( + song.artist + )} + + {isEditing ? ( + + setEditReleaseYear( + e.target.value === '' ? '' : Number(e.target.value) + ) + } + style={{ width: '5rem', padding: '0.25rem' }} + /> + ) : song.releaseYear ? ( + song.releaseYear + ) : ( + '-' + )} + + {isEditing ? ( +
+
+ {genres + .filter(g => curatorInfo?.genreIds.includes(g.id)) + .map(genre => ( + + ))} +
+
+ {song.genres + .filter( + g => !curatorInfo?.genreIds.includes(g.id) + ) + .map(g => ( + + {typeof g.name === 'string' + ? g.name + : g.name?.de ?? g.name?.en} + + ))} + {song.specials.map(s => ( + + {typeof s.name === 'string' + ? s.name + : s.name?.de ?? s.name?.en} + + ))} +
+
+ ) : ( +
+ {song.genres.map(g => ( + + {typeof g.name === 'string' + ? g.name + : g.name?.de ?? g.name?.en} + + ))} + {song.specials.map(s => ( + + {typeof s.name === 'string' + ? s.name + : s.name?.de ?? s.name?.en} + + ))} +
+ )} +
+ {new Date(song.createdAt).toLocaleDateString()} + + {song.activations ?? song.puzzles?.length ?? 0} + {ratingText} + {isEditing ? ( + + setEditExcludeFromGlobal(e.target.checked) + } + disabled={!curatorInfo?.isGlobalCurator} + /> + ) : song.excludeFromGlobal ? ( + 'Ja' + ) : ( + 'Nein' + )} + {!curatorInfo?.isGlobalCurator && ( + + Nur globale Kuratoren dürfen dieses Flag ändern. + + )} + + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Seite {page} von {totalPages} + + +
+ )} + + )} +
+
+ ); +} + + diff --git a/lib/auth.ts b/lib/auth.ts index 3496676..c34189e 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,4 +1,11 @@ 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 @@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise + */ +export async function getStaffContext(request: NextRequest): Promise { + 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 */ diff --git a/messages/de.json b/messages/de.json index 7abda15..02f312c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -157,7 +157,15 @@ "artist": "Interpret", "actions": "Aktionen", "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": { "title": "Über Hördle & Impressum", diff --git a/messages/en.json b/messages/en.json index 0edfa00..f4fe36c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -157,7 +157,15 @@ "artist": "Artist", "actions": "Actions", "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": { "title": "About Hördle & Imprint", diff --git a/prisma/migrations/20251203101200_kuratoren_accounts/migration.sql b/prisma/migrations/20251203101200_kuratoren_accounts/migration.sql new file mode 100644 index 0000000..7f14e64 --- /dev/null +++ b/prisma/migrations/20251203101200_kuratoren_accounts/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 26f2268..e80290e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,7 @@ model Genre { active Boolean @default(true) songs Song[] dailyPuzzles DailyPuzzle[] + curatorGenres CuratorGenre[] } model Special { @@ -48,6 +49,7 @@ model Special { songs SpecialSong[] puzzles DailyPuzzle[] news News[] + curatorSpecials CuratorSpecial[] } model SpecialSong { @@ -102,6 +104,40 @@ model PlayerState { @@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 { id Int @id @default(autoincrement()) locale String