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:
@@ -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<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(() => {
|
||||
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() {
|
||||
<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' }}>
|
||||
<table
|
||||
style={{
|
||||
@@ -1156,6 +1445,21 @@ export default function CuratorPageClient() {
|
||||
>
|
||||
<thead>
|
||||
<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
|
||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||
onClick={() => handleSort('id')}
|
||||
@@ -1214,8 +1518,26 @@ export default function CuratorPageClient() {
|
||||
? `${(song.averageRating || 0).toFixed(1)} (${song.ratingCount})`
|
||||
: '-';
|
||||
|
||||
const isSelected = selectedSongIds.has(song.id);
|
||||
|
||||
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' }}>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user