From 296a227d224b4c46fff28f9ee5c4e868bf3f9506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Thu, 4 Dec 2025 00:38:08 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Batch-Edit-Funktionalit=C3=A4t=20f?= =?UTF-8?q?=C3=BCr=20Curator=20Track-Liste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue API-Route /api/songs/batch für Batch-Updates - Checkbox-Spalte in Tabelle mit Select-All-Funktionalität - Batch-Edit-Toolbar mit Genre/Special Toggle, Artist-Änderung und Exclude Global Flag - Visuelle Hervorhebung ausgewählter Zeilen - Unterstützt Toggle-Modus für Genres/Specials (hinzufügen/entfernen) - Validiert Kurator-Berechtigungen für jeden Song - Transaktionsbasierte Updates für Konsistenz --- app/api/songs/batch/route.ts | 265 ++++++++++++++++++++++++ app/curator/CuratorPageClient.tsx | 324 +++++++++++++++++++++++++++++- 2 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 app/api/songs/batch/route.ts diff --git a/app/api/songs/batch/route.ts b/app/api/songs/batch/route.ts new file mode 100644 index 0000000..3bce117 --- /dev/null +++ b/app/api/songs/batch/route.ts @@ -0,0 +1,265 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; +import { 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) => { + if (s?.specialId != null) return s.specialId; + if (s?.special?.id != null) return s.special.id; + if (s?.id != null && s?.specialId == null && s?.special == null) return s.id; + return undefined; + }) + .filter((id: any): id is number => typeof id === 'number'); + + 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; +} + +export async function POST(request: Request) { + const { error, context } = await requireStaffAuth(request as unknown as NextRequest); + if (error || !context) return error!; + + try { + const { songIds, genreToggleIds, specialToggleIds, artist, excludeFromGlobal } = await request.json(); + + if (!songIds || !Array.isArray(songIds) || songIds.length === 0) { + return NextResponse.json({ error: 'Missing or invalid songIds array' }, { status: 400 }); + } + + // Validate that at least one operation is requested + const hasGenreToggle = genreToggleIds && Array.isArray(genreToggleIds) && genreToggleIds.length > 0; + const hasSpecialToggle = specialToggleIds && Array.isArray(specialToggleIds) && specialToggleIds.length > 0; + const hasArtistChange = artist !== undefined && artist !== null && artist.trim() !== ''; + const hasExcludeGlobalChange = excludeFromGlobal !== undefined; + + if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) { + return NextResponse.json({ error: 'No update operations specified' }, { status: 400 }); + } + + // Validate artist if provided + if (hasArtistChange && artist.trim() === '') { + return NextResponse.json({ error: 'Artist cannot be empty' }, { status: 400 }); + } + + // Validate excludeFromGlobal permission + if (hasExcludeGlobalChange && context.role === 'curator' && !context.curator.isGlobalCurator) { + return NextResponse.json( + { error: 'Forbidden: Only global curators or admins can change global playlist flag' }, + { status: 403 } + ); + } + + let assignments: { genreIds: Set; specialIds: Set } | null = null; + if (context.role === 'curator') { + assignments = await getCuratorAssignments(context.curator.id); + + // Validate genre/special toggles are within curator's assignments + if (hasGenreToggle) { + const invalidGenre = genreToggleIds.some((id: number) => !assignments.genreIds.has(id)); + if (invalidGenre) { + return NextResponse.json( + { error: 'Curators may only toggle their own genres' }, + { status: 403 } + ); + } + } + + if (hasSpecialToggle) { + const invalidSpecial = specialToggleIds.some((id: number) => !assignments.specialIds.has(id)); + if (invalidSpecial) { + return NextResponse.json( + { error: 'Curators may only toggle their own specials' }, + { status: 403 } + ); + } + } + } + + // Load all songs with relations for permission checks + const songs = await prisma.song.findMany({ + where: { id: { in: songIds.map((id: any) => Number(id)) } }, + include: { + genres: true, + specials: { + include: { + special: true + } + }, + }, + }); + + if (songs.length === 0) { + return NextResponse.json({ error: 'No songs found' }, { status: 404 }); + } + + // Filter songs that can be edited + const editableSongs = context.role === 'admin' + ? songs + : songs.filter(song => curatorCanEditSong(context, song, assignments!)); + + if (editableSongs.length === 0) { + return NextResponse.json( + { error: 'No songs can be edited with current permissions' }, + { status: 403 } + ); + } + + const results = { + total: songIds.length, + processed: editableSongs.length, + skipped: songs.length - editableSongs.length, + success: 0, + errors: [] as Array<{ songId: number; error: string }>, + }; + + // Process each song in a transaction + for (const song of editableSongs) { + try { + await prisma.$transaction(async (tx) => { + const updateData: any = {}; + + // Handle artist change + if (hasArtistChange) { + updateData.artist = artist.trim(); + } + + // Handle excludeFromGlobal change + if (hasExcludeGlobalChange) { + updateData.excludeFromGlobal = excludeFromGlobal; + } + + // Handle genre toggles + if (hasGenreToggle) { + const currentGenreIds = song.genres.map(g => g.id); + const genreIdsToToggle = genreToggleIds as number[]; + + // Determine which genres to add/remove + const genresToAdd = genreIdsToToggle.filter(id => !currentGenreIds.includes(id)); + const genresToRemove = genreIdsToToggle.filter(id => currentGenreIds.includes(id)); + + // For curators, preserve genres they can't manage + let finalGenreIds: number[]; + if (context.role === 'curator') { + const fixedGenreIds = currentGenreIds.filter(gid => !assignments!.genreIds.has(gid)); + const managedGenreIds = currentGenreIds + .filter(gid => assignments!.genreIds.has(gid) && !genresToRemove.includes(gid)) + .concat(genresToAdd); + finalGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds])); + } else { + const newGenreIds = currentGenreIds + .filter(id => !genresToRemove.includes(id)) + .concat(genresToAdd); + finalGenreIds = Array.from(new Set(newGenreIds)); + } + + updateData.genres = { + set: finalGenreIds.map(gId => ({ id: gId })) + }; + } + + // Update song basic data + if (Object.keys(updateData).length > 0) { + await tx.song.update({ + where: { id: song.id }, + data: updateData, + }); + } + + // Handle special toggles + if (hasSpecialToggle) { + const currentSpecials = await tx.specialSong.findMany({ + where: { songId: song.id } + }); + const currentSpecialIds = currentSpecials.map(ss => ss.specialId); + const specialIdsToToggle = specialToggleIds as number[]; + + // Determine which specials to add/remove + const specialsToAdd = specialIdsToToggle.filter(id => !currentSpecialIds.includes(id)); + const specialsToRemove = specialIdsToToggle.filter(id => currentSpecialIds.includes(id)); + + // For curators, preserve specials they can't manage + let finalSpecialIds: number[]; + if (context.role === 'curator') { + const fixedSpecialIds = currentSpecialIds.filter(sid => !assignments!.specialIds.has(sid)); + const managedSpecialIds = currentSpecialIds + .filter(sid => assignments!.specialIds.has(sid) && !specialsToRemove.includes(sid)) + .concat(specialsToAdd); + finalSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds])); + } else { + const newSpecialIds = currentSpecialIds + .filter(id => !specialsToRemove.includes(id)) + .concat(specialsToAdd); + finalSpecialIds = Array.from(new Set(newSpecialIds)); + } + + // Delete removed specials + const toDelete = currentSpecialIds.filter(sid => !finalSpecialIds.includes(sid)); + if (toDelete.length > 0) { + await tx.specialSong.deleteMany({ + where: { + songId: song.id, + specialId: { in: toDelete } + } + }); + } + + // Add new specials + const toAdd = finalSpecialIds.filter(sid => !currentSpecialIds.includes(sid)); + if (toAdd.length > 0) { + await tx.specialSong.createMany({ + data: toAdd.map(specialId => ({ + songId: song.id, + specialId, + startTime: 0 + })) + }); + } + } + }); + + results.success++; + } catch (error: any) { + results.errors.push({ + songId: song.id, + error: error.message || 'Unknown error' + }); + } + } + + return NextResponse.json(results); + } catch (error) { + console.error('Error in batch update:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + diff --git a/app/curator/CuratorPageClient.tsx b/app/curator/CuratorPageClient.tsx index 7817c2d..fb1d02d 100644 --- a/app/curator/CuratorPageClient.tsx +++ b/app/curator/CuratorPageClient.tsx @@ -129,6 +129,14 @@ export default function CuratorPageClient() { const [loadingComments, setLoadingComments] = useState(false); const [showComments, setShowComments] = useState(false); + // Batch edit state + const [selectedSongIds, setSelectedSongIds] = useState>(new Set()); + const [batchGenreIds, setBatchGenreIds] = useState([]); + const [batchSpecialIds, setBatchSpecialIds] = useState([]); + const [batchArtist, setBatchArtist] = useState(''); + const [batchExcludeFromGlobal, setBatchExcludeFromGlobal] = useState(undefined); + const [isBatchUpdating, setIsBatchUpdating] = useState(false); + useEffect(() => { const authToken = localStorage.getItem('hoerdle_curator_auth'); const storedUsername = localStorage.getItem('hoerdle_curator_username'); @@ -384,6 +392,96 @@ export default function CuratorPageClient() { } }; + // Batch edit functions + const toggleSongSelection = (songId: number) => { + setSelectedSongIds(prev => { + const newSet = new Set(prev); + if (newSet.has(songId)) { + newSet.delete(songId); + } else { + // Only allow selection of editable songs + const song = songs.find(s => s.id === songId); + if (song && canEditSong(song)) { + newSet.add(songId); + } + } + return newSet; + }); + }; + + const selectAllVisible = () => { + const editableVisibleIds = visibleSongs + .filter(song => canEditSong(song)) + .map(song => song.id); + setSelectedSongIds(new Set(editableVisibleIds)); + }; + + const clearSelection = () => { + setSelectedSongIds(new Set()); + setBatchGenreIds([]); + setBatchSpecialIds([]); + setBatchArtist(''); + setBatchExcludeFromGlobal(undefined); + }; + + const handleBatchUpdate = async () => { + if (selectedSongIds.size === 0) { + setMessage(t('noSongsSelected') || 'No songs selected'); + return; + } + + const hasGenreToggle = batchGenreIds.length > 0; + const hasSpecialToggle = batchSpecialIds.length > 0; + const hasArtistChange = batchArtist.trim() !== ''; + const hasExcludeGlobalChange = batchExcludeFromGlobal !== undefined; + + if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) { + setMessage(t('noBatchOperations') || 'No batch operations specified'); + return; + } + + setIsBatchUpdating(true); + setMessage(''); + + try { + const res = await fetch('/api/songs/batch', { + method: 'POST', + headers: getCuratorAuthHeaders(), + body: JSON.stringify({ + songIds: Array.from(selectedSongIds), + genreToggleIds: hasGenreToggle ? batchGenreIds : undefined, + specialToggleIds: hasSpecialToggle ? batchSpecialIds : undefined, + artist: hasArtistChange ? batchArtist.trim() : undefined, + excludeFromGlobal: hasExcludeGlobalChange ? batchExcludeFromGlobal : undefined, + }), + }); + + if (res.ok) { + const result = await res.json(); + await fetchSongs(); + + let msg = t('batchUpdateSuccess') || `Successfully updated ${result.success} of ${result.processed} songs`; + if (result.skipped > 0) { + msg += ` (${result.skipped} skipped)`; + } + if (result.errors.length > 0) { + msg += `\nErrors: ${result.errors.map((e: any) => `Song ${e.songId}: ${e.error}`).join(', ')}`; + } + setMessage(msg); + + // Clear selection after successful update + clearSelection(); + } else { + const errText = await res.text(); + setMessage(t('batchUpdateError') || `Error: ${errText}`); + } + } catch (e) { + setMessage(t('batchUpdateNetworkError') || 'Network error during batch update'); + } finally { + setIsBatchUpdating(false); + } + }; + const handleSort = (field: SortField) => { if (sortField === field) { setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); @@ -1146,6 +1244,197 @@ export default function CuratorPageClient() {

{t('noSongsInScope')}

) : ( <> + {/* Batch Edit Toolbar */} + {selectedSongIds.size > 0 && ( +
+
+ + {t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`} + + +
+ +
+ {/* Genre Toggle */} +
+ +
+ {genres + .filter(g => curatorInfo?.genreIds?.includes(g.id)) + .map(genre => ( + + ))} +
+
+ + {/* Special Toggle */} +
+ +
+ {specials + .filter(s => curatorInfo?.specialIds?.includes(s.id)) + .map(special => ( + + ))} +
+
+ + {/* Artist Change */} +
+ + setBatchArtist(e.target.value)} + placeholder={t('batchArtistPlaceholder') || 'Enter new artist name'} + style={{ + width: '100%', + maxWidth: '400px', + padding: '0.4rem 0.6rem', + borderRadius: '0.25rem', + border: '1px solid #d1d5db', + fontSize: '0.9rem', + }} + /> +
+ + {/* Exclude Global Flag */} + {curatorInfo?.isGlobalCurator && ( +
+ + +
+ )} + + {/* Apply Button */} +
+ +
+
+
+ )} +
+ + +
+ 0 && visibleSongs.every(song => canEditSong(song) && selectedSongIds.has(song.id))} + onChange={(e) => { + if (e.target.checked) { + selectAllVisible(); + } else { + clearSelection(); + } + }} + style={{ cursor: 'pointer' }} + title={t('selectAll') || 'Select all'} + /> + handleSort('id')} @@ -1214,8 +1518,26 @@ export default function CuratorPageClient() { ? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})` : '-'; + const isSelected = selectedSongIds.has(song.id); + return ( -
+ toggleSongSelection(song.id)} + disabled={!editable} + style={{ cursor: editable ? 'pointer' : 'not-allowed' }} + title={editable ? (t('selectSong') || 'Select song') : (t('cannotEditSong') || 'Cannot edit this song')} + /> + {song.id}