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:
Hördle Bot
2025-12-04 00:38:08 +01:00
parent 50ca51b143
commit 296a227d22
2 changed files with 588 additions and 1 deletions

View File

@@ -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