feat: Batch-Edit-Funktionalität für Curator Track-Liste
- 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
This commit is contained in:
265
app/api/songs/batch/route.ts
Normal file
265
app/api/songs/batch/route.ts
Normal file
@@ -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<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) => {
|
||||||
|
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<number>; specialIds: Set<number> } | 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -129,6 +129,14 @@ export default function CuratorPageClient() {
|
|||||||
const [loadingComments, setLoadingComments] = useState(false);
|
const [loadingComments, setLoadingComments] = useState(false);
|
||||||
const [showComments, setShowComments] = useState(false);
|
const [showComments, setShowComments] = useState(false);
|
||||||
|
|
||||||
|
// Batch edit state
|
||||||
|
const [selectedSongIds, setSelectedSongIds] = useState<Set<number>>(new Set());
|
||||||
|
const [batchGenreIds, setBatchGenreIds] = useState<number[]>([]);
|
||||||
|
const [batchSpecialIds, setBatchSpecialIds] = useState<number[]>([]);
|
||||||
|
const [batchArtist, setBatchArtist] = useState('');
|
||||||
|
const [batchExcludeFromGlobal, setBatchExcludeFromGlobal] = useState<boolean | undefined>(undefined);
|
||||||
|
const [isBatchUpdating, setIsBatchUpdating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||||
const storedUsername = localStorage.getItem('hoerdle_curator_username');
|
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) => {
|
const handleSort = (field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
@@ -1146,6 +1244,197 @@ export default function CuratorPageClient() {
|
|||||||
<p>{t('noSongsInScope')}</p>
|
<p>{t('noSongsInScope')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Batch Edit Toolbar */}
|
||||||
|
{selectedSongIds.size > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: '#f0f9ff',
|
||||||
|
border: '1px solid #bae6fd',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||||
|
<strong style={{ fontSize: '1rem' }}>
|
||||||
|
{t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`}
|
||||||
|
</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSelection}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clearSelection') || 'Clear Selection'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{/* Genre Toggle */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
{t('batchToggleGenres') || 'Toggle Genres'}
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{genres
|
||||||
|
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||||
|
.map(genre => (
|
||||||
|
<label
|
||||||
|
key={genre.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: batchGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={batchGenreIds.includes(genre.id)}
|
||||||
|
onChange={() => {
|
||||||
|
setBatchGenreIds(prev =>
|
||||||
|
prev.includes(genre.id)
|
||||||
|
? prev.filter(id => id !== genre.id)
|
||||||
|
: [...prev, genre.id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{typeof genre.name === 'string'
|
||||||
|
? genre.name
|
||||||
|
: genre.name?.de ?? genre.name?.en}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Special Toggle */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
{t('batchToggleSpecials') || 'Toggle Specials'}
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{specials
|
||||||
|
.filter(s => curatorInfo?.specialIds?.includes(s.id))
|
||||||
|
.map(special => (
|
||||||
|
<label
|
||||||
|
key={special.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: batchSpecialIds.includes(special.id) ? '#fee2e2' : '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={batchSpecialIds.includes(special.id)}
|
||||||
|
onChange={() => {
|
||||||
|
setBatchSpecialIds(prev =>
|
||||||
|
prev.includes(special.id)
|
||||||
|
? prev.filter(id => id !== special.id)
|
||||||
|
: [...prev, special.id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
★{' '}
|
||||||
|
{typeof special.name === 'string'
|
||||||
|
? special.name
|
||||||
|
: special.name?.de ?? special.name?.en}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artist Change */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
{t('batchChangeArtist') || 'Change Artist'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={batchArtist}
|
||||||
|
onChange={e => 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exclude Global Flag */}
|
||||||
|
{curatorInfo?.isGlobalCurator && (
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
{t('batchExcludeGlobal') || 'Exclude from Global'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={batchExcludeFromGlobal === undefined ? '' : batchExcludeFromGlobal ? 'true' : 'false'}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.value === '') {
|
||||||
|
setBatchExcludeFromGlobal(undefined);
|
||||||
|
} else {
|
||||||
|
setBatchExcludeFromGlobal(e.target.value === 'true');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t('batchNoChange') || 'No change'}</option>
|
||||||
|
<option value="true">{t('batchExclude') || 'Exclude'}</option>
|
||||||
|
<option value="false">{t('batchInclude') || 'Include'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Apply Button */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBatchUpdate}
|
||||||
|
disabled={isBatchUpdating}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: isBatchUpdating ? '#9ca3af' : '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
cursor: isBatchUpdating ? 'not-allowed' : 'pointer',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBatchUpdating
|
||||||
|
? (t('batchUpdating') || 'Updating...')
|
||||||
|
: (t('batchApply') || 'Apply Changes')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table
|
<table
|
||||||
style={{
|
style={{
|
||||||
@@ -1156,6 +1445,21 @@ export default function CuratorPageClient() {
|
|||||||
>
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<th style={{ padding: '0.5rem', width: '40px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleSongs.length > 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'}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('id')}
|
onClick={() => handleSort('id')}
|
||||||
@@ -1214,8 +1518,26 @@ export default function CuratorPageClient() {
|
|||||||
? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})`
|
? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})`
|
||||||
: '-';
|
: '-';
|
||||||
|
|
||||||
|
const isSelected = selectedSongIds.has(song.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={song.id} style={{ borderBottom: '1px solid #f3f4f6' }}>
|
<tr
|
||||||
|
key={song.id}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid #f3f4f6',
|
||||||
|
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSongSelection(song.id)}
|
||||||
|
disabled={!editable}
|
||||||
|
style={{ cursor: editable ? 'pointer' : 'not-allowed' }}
|
||||||
|
title={editable ? (t('selectSong') || 'Select song') : (t('cannotEditSong') || 'Cannot edit this song')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td style={{ padding: '0.5rem' }}>{song.id}</td>
|
<td style={{ padding: '0.5rem' }}>{song.id}</td>
|
||||||
<td style={{ padding: '0.5rem' }}>
|
<td style={{ padding: '0.5rem' }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user