Compare commits

..

6 Commits

Author SHA1 Message Date
Hördle Bot
52a15b7504 chore: Version auf v0.1.6.1 erhöht 2025-12-04 00:43:27 +01:00
Hördle Bot
00160d9602 feat: Batch-Edit Übersetzungen hinzugefügt
- Englische und deutsche Übersetzungen für alle Batch-Edit-Funktionen
- Keys für Toolbar, Buttons, Meldungen und Fehlerbehandlung
- Unterstützt Genre/Special Toggle, Artist-Änderung und Exclude Global
2025-12-04 00:42:19 +01:00
Hördle Bot
296a227d22 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
2025-12-04 00:38:08 +01:00
Hördle Bot
50ca51b143 Enhance special ID handling in song API routes
- Updated logic to prioritize specialId and special.id for SpecialSong objects.
- Added comments for clarity on ID usage and conditions for retrieving special IDs.
- Modified API response to include related special details for better data integrity.
2025-12-04 00:24:14 +01:00
Hördle Bot
afe6e12afc Implement special selection feature in CuratorPageClient
- Added a new section for curators to select specials associated with their account.
- Introduced checkboxes for editing special selections, allowing for dynamic updates.
- Updated the display logic for specials to differentiate between selected and unselected items.
2025-12-04 00:14:27 +01:00
Hördle Bot
91b12ad859 Erweitere README.md um Kuratoren-System und Analytics-Funktionen
- Einführung eines Kuratoren-Managements mit separaten Accounts, Genre- und Special-Zuweisungen.
- Kuratoren können Songs verwalten und Spieler-Kommentare einsehen.
- Integration von Plausible Analytics für anonyme Nutzungsstatistiken und automatisches Domain-Tracking.
- Aktualisierung der Anweisungen für Kurator-Zugang und -Funktionen.
2025-12-03 23:30:31 +01:00
7 changed files with 727 additions and 24 deletions

View File

@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Bearbeitung von Metadaten.
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
- **Cover Art:**
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
@@ -42,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Live-Vorschau beim Hovern über die Waveform.
- Playback-Cursor zeigt aktuelle Abspielposition.
- Einzelne Segmente zum Testen abspielen.
- Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung.
- **News & Announcements:**
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
@@ -51,6 +51,24 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
- Verwaltung über das Admin-Dashboard.
- **Kurator-System:**
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
- **Spieler-Kommentare:**
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
- **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich.
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).
- **Kommentar-Verwaltung:** Kuratoren sehen Kommentare in ihrem Dashboard mit Badge für neue/ungelesene Nachrichten.
- **Analytics:**
- **Plausible Analytics:** Integration mit Plausible Analytics für anonyme Nutzungsstatistiken.
- **Automatisches Domain-Tracking:** Unterstützt mehrere Domains mit automatischer Erkennung.
- **Privacy-First:** Keine Cookies, kein Cross-Site-Tracking.
- 👉 **[Plausible Setup-Dokumentation](docs/PLAUSIBLE_SETUP.md)**
## Internationalisierung (i18n)
@@ -139,6 +157,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
2. **Starten:**
```bash
@@ -156,7 +175,12 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
- URL: `/de/admin` oder `/en/admin`
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
5. **Special Curation & Scheduling verwenden:**
5. **Kurator-Zugang:**
- URL: `/de/curator` oder `/en/curator`
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
6. **Special Curation & Scheduling verwenden:**
- Erstelle ein Special im Admin-Dashboard:
- Gib Name, Max Attempts und Unlock Steps ein.
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.

View 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 });
}
}

View File

@@ -35,11 +35,15 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
// - `SpecialSong` (mit `specialId`)
// - `SpecialSong` (mit Relation `special.id`)
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
// Daher zuerst specialId oder special.id prüfen.
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.id != null) return s.id;
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.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');
@@ -59,11 +63,15 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id);
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
// Daher zuerst specialId oder special.id prüfen.
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.id != null) return s.id;
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.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');
@@ -382,7 +390,11 @@ export async function PUT(request: Request) {
where: { id: Number(id) },
include: {
genres: true,
specials: true,
specials: {
include: {
special: true
}
},
},
});

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
@@ -1316,6 +1638,41 @@ export default function CuratorPageClient() {
: genre.name?.de ?? genre.name?.en}
</label>
))}
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<label
key={special.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.15rem 0.4rem',
borderRadius: '999px',
background: editSpecialIds.includes(special.id)
? '#fee2e2'
: '#f3f4f6',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={editSpecialIds.includes(special.id)}
onChange={() =>
setEditSpecialIds(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 style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres
@@ -1337,21 +1694,26 @@ export default function CuratorPageClient() {
: g.name?.de ?? g.name?.en}
</span>
))}
{song.specials.map(s => (
<span
key={`s-${s.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#fee2e2',
fontSize: '0.8rem',
}}
>
{typeof s.name === 'string'
? s.name
: s.name?.de ?? s.name?.en}
</span>
))}
{song.specials
.filter(
s => !curatorInfo?.specialIds?.includes(s.id)
)
.map(s => (
<span
key={`fixed-s-${s.id}`}
style={{
padding: '0.1rem 0.4rem',
borderRadius: '999px',
background: '#fee2e2',
fontSize: '0.8rem',
}}
>
{' '}
{typeof s.name === 'string'
? s.name
: s.name?.de ?? s.name?.en}
</span>
))}
</div>
</div>
) : (

View File

@@ -252,7 +252,27 @@
"archiveComment": "Archivieren",
"archiveCommentConfirm": "Möchtest du diesen Kommentar wirklich archivieren?",
"archiveCommentError": "Fehler beim Archivieren des Kommentars.",
"newComments": "neu"
"newComments": "neu",
"batchEditTitle": "Batch-Bearbeitung",
"clearSelection": "Auswahl aufheben",
"batchToggleGenres": "Genres umschalten",
"batchToggleSpecials": "Specials umschalten",
"batchChangeArtist": "Artist ändern",
"batchArtistPlaceholder": "Neuen Artist-Namen eingeben",
"batchExcludeGlobal": "Von Global ausschließen",
"batchNoChange": "Keine Änderung",
"batchExclude": "Ausschließen",
"batchInclude": "Einschließen",
"batchUpdating": "Aktualisiere...",
"batchApply": "Änderungen anwenden",
"selectAll": "Alle auswählen",
"selectSong": "Titel auswählen",
"cannotEditSong": "Dieser Titel kann nicht bearbeitet werden",
"noSongsSelected": "Keine Titel ausgewählt",
"noBatchOperations": "Keine Batch-Operationen angegeben",
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
"batchUpdateError": "Fehler: {error}",
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
},
"About": {
"title": "Über Hördle & Impressum",

View File

@@ -252,7 +252,27 @@
"archiveComment": "Archive",
"archiveCommentConfirm": "Do you really want to archive this comment?",
"archiveCommentError": "Error archiving comment.",
"newComments": "new"
"newComments": "new",
"batchEditTitle": "Batch Edit",
"clearSelection": "Clear Selection",
"batchToggleGenres": "Toggle Genres",
"batchToggleSpecials": "Toggle Specials",
"batchChangeArtist": "Change Artist",
"batchArtistPlaceholder": "Enter new artist name",
"batchExcludeGlobal": "Exclude from Global",
"batchNoChange": "No change",
"batchExclude": "Exclude",
"batchInclude": "Include",
"batchUpdating": "Updating...",
"batchApply": "Apply Changes",
"selectAll": "Select all",
"selectSong": "Select song",
"cannotEditSong": "Cannot edit this song",
"noSongsSelected": "No songs selected",
"noBatchOperations": "No batch operations specified",
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
"batchUpdateError": "Error: {error}",
"batchUpdateNetworkError": "Network error during batch update"
},
"About": {
"title": "About Hördle & Imprint",

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.6.0",
"version": "0.1.6.1",
"private": true,
"scripts": {
"dev": "next dev",