Compare commits
15 Commits
38148ace8d
...
v0.1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28d14ff099 | ||
|
|
b1493b44bf | ||
|
|
b8a803b76e | ||
|
|
e2bdf0fc88 | ||
|
|
2cb9af8d2b | ||
|
|
d6ad01b00e | ||
|
|
693817b18c | ||
|
|
41336e3af3 | ||
|
|
d7ec691469 | ||
|
|
5e1700712e | ||
|
|
f691384a34 | ||
|
|
f0d75c591a | ||
|
|
1f34d5813e | ||
|
|
33f8080aa8 | ||
|
|
8a102afc0e |
@@ -786,7 +786,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
|
|
||||||
const handleSaveCurator = async (e: React.FormEvent) => {
|
const handleSaveCurator = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!curatorUsername.trim()) return;
|
if (!curatorUsername.trim()) {
|
||||||
|
alert('Bitte einen Benutzernamen eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Anlegen eines neuen Kurators ist ein Passwort Pflicht.
|
||||||
|
if (!editingCuratorId && !curatorPassword.trim()) {
|
||||||
|
alert('Für neue Kuratoren muss ein Passwort gesetzt werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
username: curatorUsername.trim(),
|
username: curatorUsername.trim(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import CuratorPageInner from '../../curator/page';
|
|||||||
|
|
||||||
export default function CuratorPage() {
|
export default function CuratorPage() {
|
||||||
// Wrapper für die lokalisierte Route /[locale]/curator
|
// Wrapper für die lokalisierte Route /[locale]/curator
|
||||||
|
// Hinweis: Pfad '../../curator/page' zeigt von 'app/[locale]/curator' korrekt auf 'app/curator/page'.
|
||||||
return <CuratorPageInner />;
|
return <CuratorPageInner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, Prisma } from '@prisma/client';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
@@ -69,6 +69,15 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating curator:', error);
|
console.error('Error creating curator:', error);
|
||||||
|
|
||||||
|
// Handle unique username constraint violation explicitly
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'A curator with this username already exists.' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,6 +162,15 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating curator:', error);
|
console.error('Error updating curator:', error);
|
||||||
|
|
||||||
|
// Handle unique username constraint violation explicitly for updates
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'A curator with this username already exists.' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
app/api/public-songs/route.ts
Normal file
21
app/api/public-songs/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Öffentliche, schreibgeschützte Song-Liste für das Spiel (GuessInput etc.).
|
||||||
|
// Kein Auth, nur Lesen der nötigsten Felder.
|
||||||
|
export async function GET() {
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
artist: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(songs);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +30,19 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
|
|||||||
if (context.role === 'admin') return true;
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||||
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id);
|
// `song.specials` kann je nach Context entweder ein Array von
|
||||||
|
// - `Special` (mit `id`)
|
||||||
|
// - `SpecialSong` (mit `specialId`)
|
||||||
|
// - `SpecialSong` (mit Relation `special.id`)
|
||||||
|
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
||||||
|
const songSpecialIds = (song.specials || [])
|
||||||
|
.map((s: any) => {
|
||||||
|
if (s?.id != null) return s.id;
|
||||||
|
if (s?.specialId != null) return s.specialId;
|
||||||
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
|
|
||||||
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
|
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
|
||||||
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||||
@@ -47,7 +59,14 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
|
|||||||
if (context.role === 'admin') return true;
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||||
const songSpecialIds = (song.specials || []).map((s: any) => s.specialId ?? s.id);
|
const songSpecialIds = (song.specials || [])
|
||||||
|
.map((s: any) => {
|
||||||
|
if (s?.id != null) return s.id;
|
||||||
|
if (s?.specialId != null) return s.specialId;
|
||||||
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
|
|
||||||
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
|
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
|
||||||
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
|
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
|
||||||
@@ -59,7 +78,11 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
|
|||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
|
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) return error!;
|
||||||
|
|
||||||
const songs = await prisma.song.findMany({
|
const songs = await prisma.song.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
@@ -73,8 +96,33 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let visibleSongs = songs;
|
||||||
|
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const assignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
|
||||||
|
visibleSongs = songs.filter(song => {
|
||||||
|
const songGenreIds = song.genres.map(g => g.id);
|
||||||
|
// `song.specials` ist hier ein Array von SpecialSong mit Relation `special`.
|
||||||
|
// Es kann theoretisch verwaiste Einträge ohne `special` geben → defensiv optional chainen.
|
||||||
|
const songSpecialIds = song.specials
|
||||||
|
.map(ss => ss.special?.id)
|
||||||
|
.filter((id): id is number => typeof id === 'number');
|
||||||
|
|
||||||
|
// Songs ohne Genres/Specials sind immer sichtbar
|
||||||
|
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasGenre = songGenreIds.some(id => assignments.genreIds.has(id));
|
||||||
|
const hasSpecial = songSpecialIds.some(id => assignments.specialIds.has(id));
|
||||||
|
|
||||||
|
return hasGenre || hasSpecial;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Map to include activation count and flatten specials
|
// Map to include activation count and flatten specials
|
||||||
const songsWithActivations = songs.map(song => ({
|
const songsWithActivations = visibleSongs.map(song => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
@@ -85,7 +133,10 @@ export async function GET() {
|
|||||||
activations: song.puzzles.length,
|
activations: song.puzzles.length,
|
||||||
puzzles: song.puzzles,
|
puzzles: song.puzzles,
|
||||||
genres: song.genres,
|
genres: song.genres,
|
||||||
specials: song.specials.map(ss => ss.special),
|
// Nur Specials mit existierender Relation durchreichen, um undefinierte Einträge zu vermeiden.
|
||||||
|
specials: song.specials
|
||||||
|
.map(ss => ss.special)
|
||||||
|
.filter((s): s is any => !!s),
|
||||||
averageRating: song.averageRating,
|
averageRating: song.averageRating,
|
||||||
ratingCount: song.ratingCount,
|
ratingCount: song.ratingCount,
|
||||||
excludeFromGlobal: song.excludeFromGlobal,
|
excludeFromGlobal: song.excludeFromGlobal,
|
||||||
@@ -411,7 +462,8 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveGenreIds && effectiveGenreIds.length > 0) {
|
// Wenn effectiveGenreIds definiert ist, auch leere Arrays übernehmen (löscht alle Zuordnungen).
|
||||||
|
if (effectiveGenreIds !== undefined) {
|
||||||
data.genres = {
|
data.genres = {
|
||||||
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface Genre {
|
interface Genre {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -56,6 +57,7 @@ function getCuratorUploadHeaders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CuratorPage() {
|
export default function CuratorPage() {
|
||||||
|
const t = useTranslations('Curator');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
@@ -93,7 +95,7 @@ export default function CuratorPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedFilter, setSelectedFilter] = useState<string>('');
|
const [selectedFilter, setSelectedFilter] = useState<string>('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 10;
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ export default function CuratorPage() {
|
|||||||
setCuratorInfo(data);
|
setCuratorInfo(data);
|
||||||
localStorage.setItem('hoerdle_curator_is_global', String(data.isGlobalCurator));
|
localStorage.setItem('hoerdle_curator_is_global', String(data.isGlobalCurator));
|
||||||
} else {
|
} else {
|
||||||
setMessage('Fehler beim Laden der Kuratoren-Informationen.');
|
setMessage(t('loadCuratorError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,7 +139,7 @@ export default function CuratorPage() {
|
|||||||
const data: Song[] = await res.json();
|
const data: Song[] = await res.json();
|
||||||
setSongs(data);
|
setSongs(data);
|
||||||
} else {
|
} else {
|
||||||
setMessage('Fehler beim Laden der Songs.');
|
setMessage(t('loadSongsError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,10 +178,10 @@ export default function CuratorPage() {
|
|||||||
await bootstrapCuratorData();
|
await bootstrapCuratorData();
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json().catch(() => null);
|
const err = await res.json().catch(() => null);
|
||||||
setMessage(err?.error || 'Login fehlgeschlagen.');
|
setMessage(err?.error || t('loginFailed'));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage('Netzwerkfehler beim Login.');
|
setMessage(t('loginNetworkError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,13 +242,13 @@ export default function CuratorPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
await fetchSongs();
|
await fetchSongs();
|
||||||
setMessage('Song erfolgreich aktualisiert.');
|
setMessage(t('songUpdated'));
|
||||||
} else {
|
} else {
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
setMessage(`Fehler beim Speichern: ${errText}`);
|
setMessage(t('saveError', { error: errText }));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage('Netzwerkfehler beim Speichern.');
|
setMessage(t('saveNetworkError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,10 +276,10 @@ export default function CuratorPage() {
|
|||||||
|
|
||||||
const handleDelete = async (song: Song) => {
|
const handleDelete = async (song: Song) => {
|
||||||
if (!canDeleteSong(song)) {
|
if (!canDeleteSong(song)) {
|
||||||
setMessage('Du darfst diesen Song nicht löschen.');
|
setMessage(t('noDeletePermission'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm(`Möchtest du "${song.title}" wirklich löschen?`)) return;
|
if (!confirm(t('deleteConfirm', { title: song.title }))) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/songs', {
|
const res = await fetch('/api/songs', {
|
||||||
@@ -287,13 +289,13 @@ export default function CuratorPage() {
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await fetchSongs();
|
await fetchSongs();
|
||||||
setMessage('Song gelöscht.');
|
setMessage(t('songDeleted'));
|
||||||
} else {
|
} else {
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
setMessage(`Fehler beim Löschen: ${errText}`);
|
setMessage(t('deleteError', { error: errText }));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage('Netzwerkfehler beim Löschen.');
|
setMessage(t('deleteNetworkError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -317,7 +319,7 @@ export default function CuratorPage() {
|
|||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
setPlayingSongId(null);
|
setPlayingSongId(null);
|
||||||
setAudioElement(null);
|
setAudioElement(null);
|
||||||
alert(`Audio-Datei konnte nicht geladen werden: ${song.filename}`);
|
alert(`Audio file could not be loaded: ${song.filename}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
audio.play()
|
audio.play()
|
||||||
@@ -330,6 +332,12 @@ export default function CuratorPage() {
|
|||||||
setPlayingSongId(null);
|
setPlayingSongId(null);
|
||||||
setAudioElement(null);
|
setAudioElement(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset Zustand, wenn der Track zu Ende gespielt ist
|
||||||
|
audio.onended = () => {
|
||||||
|
setPlayingSongId(null);
|
||||||
|
setAudioElement(null);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -470,19 +478,19 @@ export default function CuratorPage() {
|
|||||||
const duplicateCount = results.filter(r => r.isDuplicate).length;
|
const duplicateCount = results.filter(r => r.isDuplicate).length;
|
||||||
const failedCount = results.filter(r => !r.success && !r.isDuplicate).length;
|
const failedCount = results.filter(r => !r.success && !r.isDuplicate).length;
|
||||||
|
|
||||||
let msg = `✅ ${successCount}/${results.length} Uploads erfolgreich.`;
|
let msg = t('uploadSummary', { success: successCount, total: results.length });
|
||||||
if (duplicateCount > 0) msg += `\n⚠️ ${duplicateCount} Duplikat(e) übersprungen.`;
|
if (duplicateCount > 0) msg += `\n` + t('uploadSummaryDuplicates', { count: duplicateCount });
|
||||||
if (failedCount > 0) msg += `\n❌ ${failedCount} fehlgeschlagen.`;
|
if (failedCount > 0) msg += `\n` + t('uploadSummaryFailed', { count: failedCount });
|
||||||
setMessage(msg);
|
setMessage(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: '480px', margin: '2rem auto', padding: '1rem' }}>
|
<main style={{ maxWidth: '480px', margin: '2rem auto', padding: '1rem' }}>
|
||||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>Kuratoren-Login</h1>
|
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>{t('loginTitle')}</h1>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
{t('loginUsername')}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
@@ -491,7 +499,7 @@ export default function CuratorPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Passwort
|
{t('loginPassword')}
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -512,7 +520,7 @@ export default function CuratorPage() {
|
|||||||
marginTop: '0.5rem',
|
marginTop: '0.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Einloggen
|
{t('loginButton')}
|
||||||
</button>
|
</button>
|
||||||
{message && (
|
{message && (
|
||||||
<p style={{ color: '#b91c1c', marginTop: '0.5rem', whiteSpace: 'pre-line' }}>{message}</p>
|
<p style={{ color: '#b91c1c', marginTop: '0.5rem', whiteSpace: 'pre-line' }}>{message}</p>
|
||||||
@@ -599,8 +607,8 @@ export default function CuratorPage() {
|
|||||||
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
|
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
|
||||||
{curatorInfo && (
|
{curatorInfo && (
|
||||||
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>
|
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>
|
||||||
Eingeloggt als <strong>{curatorInfo.username}</strong>
|
{t('loggedInAs', { username: curatorInfo.username })}
|
||||||
{curatorInfo.isGlobalCurator && ' (Globaler Kurator)'}
|
{curatorInfo.isGlobalCurator && t('globalCuratorSuffix')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -616,21 +624,19 @@ export default function CuratorPage() {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Abmelden
|
{t('logout')}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{loading && <p>Lade Daten...</p>}
|
{loading && <p>{t('loadingData')}</p>}
|
||||||
{message && (
|
{message && (
|
||||||
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
|
<p style={{ marginBottom: '1rem', color: '#b91c1c', whiteSpace: 'pre-line' }}>{message}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>Titel hochladen</h2>
|
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>{t('uploadSectionTitle')}</h2>
|
||||||
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
||||||
Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert
|
{t('uploadSectionDescription')}
|
||||||
(inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens
|
|
||||||
eines deiner Genres aus, um die Titel zuzuordnen.
|
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleBatchUpload} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '640px' }}>
|
<form onSubmit={handleBatchUpload} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '640px' }}>
|
||||||
<div
|
<div
|
||||||
@@ -651,9 +657,11 @@ export default function CuratorPage() {
|
|||||||
>
|
>
|
||||||
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📁</div>
|
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📁</div>
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
||||||
{files.length > 0 ? `${files.length} Datei(en) ausgewählt` : 'MP3-Dateien hierher ziehen'}
|
{files.length > 0
|
||||||
|
? t('dropzoneTitleWithFiles', { count: files.length })
|
||||||
|
: t('dropzoneTitleEmpty')}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666' }}>oder klicken, um Dateien auszuwählen</p>
|
<p style={{ fontSize: '0.875rem', color: '#666' }}>{t('dropzoneSubtitle')}</p>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -666,7 +674,7 @@ export default function CuratorPage() {
|
|||||||
|
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div style={{ marginBottom: '0.5rem' }}>
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>Ausgewählte Dateien:</p>
|
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>{t('selectedFilesTitle')}</p>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxHeight: '160px',
|
maxHeight: '160px',
|
||||||
@@ -696,7 +704,10 @@ export default function CuratorPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
||||||
Upload: {uploadProgress.current} / {uploadProgress.total}
|
{t('uploadProgress', {
|
||||||
|
current: uploadProgress.current,
|
||||||
|
total: uploadProgress.total,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -723,10 +734,10 @@ export default function CuratorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>Genres zuordnen</div>
|
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignGenresLabel')}</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
{genres
|
{genres
|
||||||
.filter(g => curatorInfo?.genreIds.includes(g.id))
|
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||||
.map(genre => (
|
.map(genre => (
|
||||||
<label
|
<label
|
||||||
key={genre.id}
|
key={genre.id}
|
||||||
@@ -751,7 +762,7 @@ export default function CuratorPage() {
|
|||||||
))}
|
))}
|
||||||
{curatorInfo && curatorInfo.genreIds.length === 0 && (
|
{curatorInfo && curatorInfo.genreIds.length === 0 && (
|
||||||
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
|
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
|
||||||
Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.
|
{t('noAssignedGenres')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -770,7 +781,7 @@ export default function CuratorPage() {
|
|||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isUploading ? 'Lade hoch...' : 'Upload starten'}
|
{isUploading ? t('uploadButtonUploading') : t('uploadButtonIdle')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{uploadResults.length > 0 && (
|
{uploadResults.length > 0 && (
|
||||||
@@ -784,14 +795,14 @@ export default function CuratorPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{uploadResults.map((r, idx) => (
|
{uploadResults.map((r, idx) => (
|
||||||
<div key={idx} style={{ marginBottom: '0.25rem' }}>
|
<div key={idx} style={{ marginBottom: '0.25rem' }}>
|
||||||
<strong>{r.filename}</strong> –{' '}
|
<strong>{r.filename}</strong> –{' '}
|
||||||
{r.success
|
{r.success
|
||||||
? '✅ erfolgreich'
|
? t('uploadResultSuccess')
|
||||||
: r.isDuplicate
|
: r.isDuplicate
|
||||||
? `⚠️ Duplikat: ${r.error}`
|
? t('uploadResultDuplicate', { error: r.error })
|
||||||
: `❌ Fehler: ${r.error}`}
|
: t('uploadResultError', { error: r.error })}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -800,19 +811,17 @@ export default function CuratorPage() {
|
|||||||
|
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
|
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
|
||||||
Titel in deinen Genres & Specials ({filteredSongs.length} Titel)
|
{t('tracklistTitle', { count: filteredSongs.length })}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
|
||||||
Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind.
|
{t('tracklistDescription')}
|
||||||
Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist.
|
|
||||||
Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Suche & Filter */}
|
{/* Suche & Filter */}
|
||||||
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nach Titel oder Artist suchen..."
|
placeholder={t('searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
@@ -839,11 +848,11 @@ export default function CuratorPage() {
|
|||||||
border: '1px solid #d1d5db',
|
border: '1px solid #d1d5db',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">Alle Inhalte</option>
|
<option value="">{t('filterAll')}</option>
|
||||||
<option value="no-global">🚫 Ohne Global</option>
|
<option value="no-global">{t('filterNoGlobal')}</option>
|
||||||
<optgroup label="Genres">
|
<optgroup label="Genres">
|
||||||
{genres
|
{genres
|
||||||
.filter(g => curatorInfo?.genreIds.includes(g.id))
|
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||||
.map(genre => (
|
.map(genre => (
|
||||||
<option key={genre.id} value={`genre:${genre.id}`}>
|
<option key={genre.id} value={`genre:${genre.id}`}>
|
||||||
{typeof genre.name === 'string'
|
{typeof genre.name === 'string'
|
||||||
@@ -854,7 +863,7 @@ export default function CuratorPage() {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Specials">
|
<optgroup label="Specials">
|
||||||
{specials
|
{specials
|
||||||
.filter(s => curatorInfo?.specialIds.includes(s.id))
|
.filter(s => curatorInfo?.specialIds?.includes(s.id))
|
||||||
.map(special => (
|
.map(special => (
|
||||||
<option key={special.id} value={`special:${special.id}`}>
|
<option key={special.id} value={`special:${special.id}`}>
|
||||||
★{' '}
|
★{' '}
|
||||||
@@ -882,13 +891,13 @@ export default function CuratorPage() {
|
|||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Filter zurücksetzen
|
{t('filterReset')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visibleSongs.length === 0 ? (
|
{visibleSongs.length === 0 ? (
|
||||||
<p>Keine passenden Songs in deinen Genres/Specials gefunden.</p>
|
<p>{t('noSongsInScope')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
@@ -905,48 +914,48 @@ export default function CuratorPage() {
|
|||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('id')}
|
onClick={() => handleSort('id')}
|
||||||
>
|
>
|
||||||
ID {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
|
{t('columnId')} {sortField === 'id' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th style={{ padding: '0.5rem' }}>Play</th>
|
<th style={{ padding: '0.5rem' }}>{t('columnPlay')}</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('title')}
|
onClick={() => handleSort('title')}
|
||||||
>
|
>
|
||||||
Titel {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
{t('columnTitle')} {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('artist')}
|
onClick={() => handleSort('artist')}
|
||||||
>
|
>
|
||||||
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
{t('columnArtist')} {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('releaseYear')}
|
onClick={() => handleSort('releaseYear')}
|
||||||
>
|
>
|
||||||
Jahr {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
|
{t('columnYear')} {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th style={{ padding: '0.5rem' }}>Genres / Specials</th>
|
<th style={{ padding: '0.5rem' }}>{t('columnGenresSpecials')}</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('createdAt')}
|
onClick={() => handleSort('createdAt')}
|
||||||
>
|
>
|
||||||
Hinzugefügt {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
{t('columnAdded')} {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('activations')}
|
onClick={() => handleSort('activations')}
|
||||||
>
|
>
|
||||||
Aktivierungen {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
|
{t('columnActivations')} {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
style={{ padding: '0.5rem', cursor: 'pointer' }}
|
||||||
onClick={() => handleSort('averageRating')}
|
onClick={() => handleSort('averageRating')}
|
||||||
>
|
>
|
||||||
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
|
{t('columnRating')} {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th style={{ padding: '0.5rem' }}>Exclude Global</th>
|
<th style={{ padding: '0.5rem' }}>{t('columnExcludeGlobal')}</th>
|
||||||
<th style={{ padding: '0.5rem' }}>Aktionen</th>
|
<th style={{ padding: '0.5rem' }}>{t('columnActions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -974,8 +983,8 @@ export default function CuratorPage() {
|
|||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
playingSongId === song.id
|
playingSongId === song.id
|
||||||
? 'Pause'
|
? t('pause')
|
||||||
: 'Abspielen'
|
: t('play')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{playingSongId === song.id ? '⏸️' : '▶️'}
|
{playingSongId === song.id ? '⏸️' : '▶️'}
|
||||||
@@ -1028,7 +1037,7 @@ export default function CuratorPage() {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
{genres
|
{genres
|
||||||
.filter(g => curatorInfo?.genreIds.includes(g.id))
|
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||||
.map(genre => (
|
.map(genre => (
|
||||||
<label
|
<label
|
||||||
key={genre.id}
|
key={genre.id}
|
||||||
@@ -1065,7 +1074,7 @@ export default function CuratorPage() {
|
|||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
{song.genres
|
{song.genres
|
||||||
.filter(
|
.filter(
|
||||||
g => !curatorInfo?.genreIds.includes(g.id)
|
g => !curatorInfo?.genreIds?.includes(g.id)
|
||||||
)
|
)
|
||||||
.map(g => (
|
.map(g => (
|
||||||
<span
|
<span
|
||||||
@@ -1150,9 +1159,9 @@ export default function CuratorPage() {
|
|||||||
disabled={!curatorInfo?.isGlobalCurator}
|
disabled={!curatorInfo?.isGlobalCurator}
|
||||||
/>
|
/>
|
||||||
) : song.excludeFromGlobal ? (
|
) : song.excludeFromGlobal ? (
|
||||||
'Ja'
|
t('excludeGlobalYes')
|
||||||
) : (
|
) : (
|
||||||
'Nein'
|
t('excludeGlobalNo')
|
||||||
)}
|
)}
|
||||||
{!curatorInfo?.isGlobalCurator && (
|
{!curatorInfo?.isGlobalCurator && (
|
||||||
<span
|
<span
|
||||||
@@ -1162,7 +1171,7 @@ export default function CuratorPage() {
|
|||||||
color: '#9ca3af',
|
color: '#9ca3af',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Nur globale Kuratoren dürfen dieses Flag ändern.
|
{t('excludeGlobalInfo')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -1246,50 +1255,73 @@ export default function CuratorPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination & Page Size */}
|
||||||
{totalPages > 1 && (
|
<div
|
||||||
<div
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
gap: '0.75rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
padding: '0.3rem 0.6rem',
|
||||||
justifyContent: 'space-between',
|
borderRadius: '0.25rem',
|
||||||
alignItems: 'center',
|
border: '1px solid #d1d5db',
|
||||||
marginTop: '0.75rem',
|
background: page === 1 ? '#f3f4f6' : '#fff',
|
||||||
fontSize: '0.875rem',
|
cursor: page === 1 ? 'not-allowed' : 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{t('paginationPrev')}
|
||||||
type="button"
|
</button>
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
<span style={{ color: '#666' }}>
|
||||||
disabled={page === 1}
|
{t('paginationLabel', { page, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
<span>{t('pageSizeLabel')}</span>
|
||||||
|
<select
|
||||||
|
value={itemsPerPage}
|
||||||
|
onChange={e => {
|
||||||
|
const value = parseInt(e.target.value, 10) || 10;
|
||||||
|
const safeValue = Math.min(100, Math.max(1, value));
|
||||||
|
setItemsPerPage(safeValue);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.3rem 0.6rem',
|
padding: '0.25rem 0.5rem',
|
||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
border: '1px solid #d1d5db',
|
border: '1px solid #d1d5db',
|
||||||
background: page === 1 ? '#f3f4f6' : '#fff',
|
|
||||||
cursor: page === 1 ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zurück
|
{[10, 25, 50, 100].map(size => (
|
||||||
</button>
|
<option key={size} value={size}>
|
||||||
<span style={{ color: '#666' }}>
|
{size}
|
||||||
Seite {page} von {totalPages}
|
</option>
|
||||||
</span>
|
))}
|
||||||
<button
|
</select>
|
||||||
type="button"
|
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
style={{
|
|
||||||
padding: '0.3rem 0.6rem',
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
border: '1px solid #d1d5db',
|
|
||||||
background: page === totalPages ? '#f3f4f6' : '#fff',
|
|
||||||
cursor: page === totalPages ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Weiter
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.6rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
background: page === totalPages ? '#f3f4f6' : '#fff',
|
||||||
|
cursor: page === totalPages ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('paginationNext')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -391,6 +391,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Aktuelle Attempt-Anzeige:
|
||||||
|
// - Während des Spiels: nächster Versuch = guesses.length + 1
|
||||||
|
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
|
||||||
|
const currentAttempt = (gameState.isSolved || gameState.isFailed)
|
||||||
|
? gameState.guesses.length
|
||||||
|
: gameState.guesses.length + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
@@ -403,7 +410,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
<main className="game-board">
|
<main className="game-board">
|
||||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||||
<div id="tour-status" className="status-bar">
|
<div id="tour-status" className="status-bar">
|
||||||
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
|
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
|
||||||
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -512,14 +519,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.25rem', textAlign: 'center' }}>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>
|
||||||
|
{t('shareExplanation')}
|
||||||
|
</p>
|
||||||
|
<button onClick={handleShare} className="btn-primary">
|
||||||
|
{shareText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{statistics && <Statistics statistics={statistics} />}
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
|
||||||
{shareText}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/songs')
|
fetch('/api/public-songs')
|
||||||
.then(res => res.json())
|
.then(res => {
|
||||||
.then(data => setSongs(data));
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load songs: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setSongs(data);
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected songs payload in GuessInput:', data);
|
||||||
|
setSongs([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error loading songs for GuessInput:', err);
|
||||||
|
setSongs([]);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
||||||
"theSongWas": "Das Lied war:",
|
"theSongWas": "Das Lied war:",
|
||||||
"score": "Punkte",
|
"score": "Punkte",
|
||||||
|
"shareExplanation": "Teile dein Ergebnis mit Freund:innen – so hilfst du, Hördle bekannter zu machen.",
|
||||||
"scoreBreakdown": "Punkteaufschlüsselung",
|
"scoreBreakdown": "Punkteaufschlüsselung",
|
||||||
"albumCover": "Album-Cover",
|
"albumCover": "Album-Cover",
|
||||||
"released": "Veröffentlicht",
|
"released": "Veröffentlicht",
|
||||||
@@ -167,6 +168,72 @@
|
|||||||
"assignedSpecials": "Zugeordnete Specials",
|
"assignedSpecials": "Zugeordnete Specials",
|
||||||
"noCurators": "Noch keine Kuratoren angelegt."
|
"noCurators": "Noch keine Kuratoren angelegt."
|
||||||
},
|
},
|
||||||
|
"Curator": {
|
||||||
|
"loginTitle": "Kuratoren-Login",
|
||||||
|
"loginUsername": "Benutzername",
|
||||||
|
"loginPassword": "Passwort",
|
||||||
|
"loginButton": "Einloggen",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"loginFailed": "Login fehlgeschlagen.",
|
||||||
|
"loginNetworkError": "Netzwerkfehler beim Login.",
|
||||||
|
"loadCuratorError": "Fehler beim Laden der Kuratoren-Informationen.",
|
||||||
|
"loadSongsError": "Fehler beim Laden der Songs.",
|
||||||
|
"songUpdated": "Song erfolgreich aktualisiert.",
|
||||||
|
"saveError": "Fehler beim Speichern: {error}",
|
||||||
|
"saveNetworkError": "Netzwerkfehler beim Speichern.",
|
||||||
|
"noDeletePermission": "Du darfst diesen Song nicht löschen.",
|
||||||
|
"deleteConfirm": "Möchtest du \"{title}\" wirklich löschen?",
|
||||||
|
"songDeleted": "Song gelöscht.",
|
||||||
|
"deleteError": "Fehler beim Löschen: {error}",
|
||||||
|
"deleteNetworkError": "Netzwerkfehler beim Löschen.",
|
||||||
|
"uploadSectionTitle": "Titel hochladen",
|
||||||
|
"uploadSectionDescription": "Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert (inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens eines deiner Genres aus, um die Titel zuzuordnen.",
|
||||||
|
"dropzoneTitleEmpty": "MP3-Dateien hierher ziehen",
|
||||||
|
"dropzoneTitleWithFiles": "{count} Datei(en) ausgewählt",
|
||||||
|
"dropzoneSubtitle": "oder klicken, um Dateien auszuwählen",
|
||||||
|
"selectedFilesTitle": "Ausgewählte Dateien:",
|
||||||
|
"uploadProgress": "Upload: {current} / {total}",
|
||||||
|
"assignGenresLabel": "Genres zuordnen",
|
||||||
|
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
|
||||||
|
"uploadButtonIdle": "Upload starten",
|
||||||
|
"uploadButtonUploading": "Lade hoch...",
|
||||||
|
"uploadSummary": "✅ {success}/{total} Uploads erfolgreich.",
|
||||||
|
"uploadSummaryDuplicates": "⚠️ {count} Duplikat(e) übersprungen.",
|
||||||
|
"uploadSummaryFailed": "❌ {count} fehlgeschlagen.",
|
||||||
|
"uploadResultSuccess": "✅ erfolgreich",
|
||||||
|
"uploadResultDuplicate": "⚠️ Duplikat: {error}",
|
||||||
|
"uploadResultError": "❌ Fehler: {error}",
|
||||||
|
"tracklistTitle": "Titel in deinen Genres & Specials ({count} Titel)",
|
||||||
|
"tracklistDescription": "Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist. Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.",
|
||||||
|
"searchPlaceholder": "Nach Titel oder Artist suchen...",
|
||||||
|
"filterAll": "Alle Inhalte",
|
||||||
|
"filterNoGlobal": "🚫 Ohne Global",
|
||||||
|
"filterReset": "Filter zurücksetzen",
|
||||||
|
"noSongsInScope": "Keine passenden Songs in deinen Genres/Specials gefunden.",
|
||||||
|
"columnId": "ID",
|
||||||
|
"columnPlay": "Play",
|
||||||
|
"columnTitle": "Titel",
|
||||||
|
"columnArtist": "Artist",
|
||||||
|
"columnYear": "Jahr",
|
||||||
|
"columnGenresSpecials": "Genres / Specials",
|
||||||
|
"columnAdded": "Hinzugefügt",
|
||||||
|
"columnActivations": "Aktivierungen",
|
||||||
|
"columnRating": "Rating",
|
||||||
|
"columnExcludeGlobal": "Exclude Global",
|
||||||
|
"columnActions": "Aktionen",
|
||||||
|
"play": "Abspielen",
|
||||||
|
"pause": "Pause",
|
||||||
|
"excludeGlobalYes": "Ja",
|
||||||
|
"excludeGlobalNo": "Nein",
|
||||||
|
"excludeGlobalInfo": "Nur globale Kuratoren dürfen dieses Flag ändern.",
|
||||||
|
"paginationPrev": "Zurück",
|
||||||
|
"paginationNext": "Weiter",
|
||||||
|
"paginationLabel": "Seite {page} von {total}",
|
||||||
|
"loadingData": "Lade Daten...",
|
||||||
|
"loggedInAs": "Eingeloggt als {username}",
|
||||||
|
"globalCuratorSuffix": " (Globaler Kurator)",
|
||||||
|
"pageSizeLabel": "Pro Seite:"
|
||||||
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "Über Hördle & Impressum",
|
"title": "Über Hördle & Impressum",
|
||||||
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
||||||
"theSongWas": "The song was:",
|
"theSongWas": "The song was:",
|
||||||
"score": "Score",
|
"score": "Score",
|
||||||
|
"shareExplanation": "Share your result with friends – your support helps Hördle grow.",
|
||||||
"scoreBreakdown": "Score Breakdown",
|
"scoreBreakdown": "Score Breakdown",
|
||||||
"albumCover": "Album Cover",
|
"albumCover": "Album Cover",
|
||||||
"released": "Released",
|
"released": "Released",
|
||||||
@@ -167,6 +168,72 @@
|
|||||||
"assignedSpecials": "Assigned specials",
|
"assignedSpecials": "Assigned specials",
|
||||||
"noCurators": "No curators created yet."
|
"noCurators": "No curators created yet."
|
||||||
},
|
},
|
||||||
|
"Curator": {
|
||||||
|
"loginTitle": "Curator Login",
|
||||||
|
"loginUsername": "Username",
|
||||||
|
"loginPassword": "Password",
|
||||||
|
"loginButton": "Log in",
|
||||||
|
"logout": "Logout",
|
||||||
|
"loginFailed": "Login failed.",
|
||||||
|
"loginNetworkError": "Network error during login.",
|
||||||
|
"loadCuratorError": "Failed to load curator information.",
|
||||||
|
"loadSongsError": "Failed to load songs.",
|
||||||
|
"songUpdated": "Song updated successfully.",
|
||||||
|
"saveError": "Error while saving: {error}",
|
||||||
|
"saveNetworkError": "Network error while saving.",
|
||||||
|
"noDeletePermission": "You are not allowed to delete this song.",
|
||||||
|
"deleteConfirm": "Do you really want to delete \"{title}\"?",
|
||||||
|
"songDeleted": "Song deleted.",
|
||||||
|
"deleteError": "Error while deleting: {error}",
|
||||||
|
"deleteNetworkError": "Network error while deleting.",
|
||||||
|
"uploadSectionTitle": "Upload titles",
|
||||||
|
"uploadSectionDescription": "Drag one or more MP3 files here or select them. The titles will be analysed automatically (including detection of the release year) and excluded from the global playlist. Select at least one of your genres to assign the titles.",
|
||||||
|
"dropzoneTitleEmpty": "Drag MP3 files here",
|
||||||
|
"dropzoneTitleWithFiles": "{count} file(s) selected",
|
||||||
|
"dropzoneSubtitle": "or click to select files",
|
||||||
|
"selectedFilesTitle": "Selected files:",
|
||||||
|
"uploadProgress": "Upload: {current} / {total}",
|
||||||
|
"assignGenresLabel": "Assign genres",
|
||||||
|
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
|
||||||
|
"uploadButtonIdle": "Start upload",
|
||||||
|
"uploadButtonUploading": "Uploading...",
|
||||||
|
"uploadSummary": "✅ {success}/{total} uploads successful.",
|
||||||
|
"uploadSummaryDuplicates": "⚠️ {count} duplicate(s) skipped.",
|
||||||
|
"uploadSummaryFailed": "❌ {count} failed.",
|
||||||
|
"uploadResultSuccess": "✅ successful",
|
||||||
|
"uploadResultDuplicate": "⚠️ Duplicate: {error}",
|
||||||
|
"uploadResultError": "❌ Error: {error}",
|
||||||
|
"tracklistTitle": "Titles in your genres & specials ({count} titles)",
|
||||||
|
"tracklistDescription": "You can edit songs that are assigned to at least one of your genres or specials. Deletion is only allowed if a song is assigned exclusively to your genres/specials. Genres, specials, news and political statements can only be managed by the admin.",
|
||||||
|
"searchPlaceholder": "Search by title or artist...",
|
||||||
|
"filterAll": "All content",
|
||||||
|
"filterNoGlobal": "🚫 No global",
|
||||||
|
"filterReset": "Reset filters",
|
||||||
|
"noSongsInScope": "No matching songs in your genres/specials.",
|
||||||
|
"columnId": "ID",
|
||||||
|
"columnPlay": "Play",
|
||||||
|
"columnTitle": "Title",
|
||||||
|
"columnArtist": "Artist",
|
||||||
|
"columnYear": "Year",
|
||||||
|
"columnGenresSpecials": "Genres / Specials",
|
||||||
|
"columnAdded": "Added",
|
||||||
|
"columnActivations": "Activations",
|
||||||
|
"columnRating": "Rating",
|
||||||
|
"columnExcludeGlobal": "Exclude global",
|
||||||
|
"columnActions": "Actions",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"excludeGlobalYes": "Yes",
|
||||||
|
"excludeGlobalNo": "No",
|
||||||
|
"excludeGlobalInfo": "Only global curators may change this flag.",
|
||||||
|
"paginationPrev": "Previous",
|
||||||
|
"paginationNext": "Next",
|
||||||
|
"paginationLabel": "Page {page} of {total}",
|
||||||
|
"loadingData": "Loading data...",
|
||||||
|
"loggedInAs": "Logged in as {username}",
|
||||||
|
"globalCuratorSuffix": " (Global curator)",
|
||||||
|
"pageSizeLabel": "Per page:"
|
||||||
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "About Hördle & Imprint",
|
"title": "About Hördle & Imprint",
|
||||||
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
|
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.4.11",
|
"version": "0.1.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user