Compare commits
6 Commits
v0.1.6.0
...
52a15b7504
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a15b7504 | ||
|
|
00160d9602 | ||
|
|
296a227d22 | ||
|
|
50ca51b143 | ||
|
|
afe6e12afc | ||
|
|
91b12ad859 |
28
README.md
28
README.md
@@ -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.
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.6.0",
|
||||
"version": "0.1.6.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user