Compare commits

..

13 Commits

Author SHA1 Message Date
Hördle Bot
28d14ff099 chore: bump version to v0.1.5.0 2025-12-03 15:12:50 +01:00
Hördle Bot
b1493b44bf Game: Share-Button unter Rating platziert und kurz erläutert 2025-12-03 15:03:32 +01:00
Hördle Bot
b8a803b76e Songs-API: robuste Behandlung möglicher verwaister SpecialSong-Relationen 2025-12-03 14:56:40 +01:00
Hördle Bot
e2bdf0fc88 Game: Attempt-Anzeige nach Rätsel-Ende nicht auf nächsten Versuch springen lassen 2025-12-03 14:09:31 +01:00
Hördle Bot
2cb9af8d2b Game: öffentliche Song-Liste für GuessInput statt geschütztem /api/songs 2025-12-03 14:06:32 +01:00
Hördle Bot
d6ad01b00e Curator-UI: sichere Optional-Chains für Genre-Filter 2025-12-03 13:46:58 +01:00
Hördle Bot
693817b18c Curator-Song-Update: Genre-Zuordnungen auch bei leerem Array korrekt übernehmen 2025-12-03 13:42:02 +01:00
Hördle Bot
41336e3af3 Curators API: aussagekräftige Fehler bei doppelten Usernames (P2002) 2025-12-03 13:37:59 +01:00
Hördle Bot
d7ec691469 Curator: Optional Chaining für Genre/Special-Filter abgesichert 2025-12-03 13:31:38 +01:00
Hördle Bot
5e1700712e Fix: Kuratoren-Scope für Specials & Audio-Playback im Curator-Dashboard 2025-12-03 13:25:43 +01:00
Hördle Bot
f691384a34 API: Auth & Scope für Song-GET, Kommentar für Kurator-Wrapper 2025-12-03 13:17:31 +01:00
Hördle Bot
f0d75c591a Admin: Validierung für Kuratoren-Passwort bei Neuanlage 2025-12-03 13:13:02 +01:00
Hördle Bot
1f34d5813e Fix: Kuratoren-Berechtigungscheck für Specials vereinheitlicht 2025-12-03 13:11:12 +01:00
11 changed files with 160 additions and 22 deletions

View File

@@ -786,7 +786,16 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const handleSaveCurator = async (e: React.FormEvent) => {
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 = {
username: curatorUsername.trim(),

View File

@@ -4,6 +4,7 @@ import CuratorPageInner from '../../curator/page';
export default function CuratorPage() {
// Wrapper für die lokalisierte Route /[locale]/curator
// Hinweis: Pfad '../../curator/page' zeigt von 'app/[locale]/curator' korrekt auf 'app/curator/page'.
return <CuratorPageInner />;
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, Prisma } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { requireAdminAuth } from '@/lib/auth';
@@ -69,6 +69,15 @@ export async function POST(request: NextRequest) {
});
} catch (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 });
}
}
@@ -153,6 +162,15 @@ export async function PUT(request: NextRequest) {
});
} catch (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 });
}
}

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

View File

@@ -30,7 +30,19 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
if (context.role === 'admin') return true;
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
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;
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 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 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({
orderBy: { createdAt: 'desc' },
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
const songsWithActivations = songs.map(song => ({
const songsWithActivations = visibleSongs.map(song => ({
id: song.id,
title: song.title,
artist: song.artist,
@@ -85,7 +133,10 @@ export async function GET() {
activations: song.puzzles.length,
puzzles: song.puzzles,
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,
ratingCount: song.ratingCount,
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 = {
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
};

View File

@@ -332,6 +332,12 @@ export default function CuratorPage() {
setPlayingSongId(null);
setAudioElement(null);
});
// Reset Zustand, wenn der Track zu Ende gespielt ist
audio.onended = () => {
setPlayingSongId(null);
setAudioElement(null);
};
}
};
@@ -731,7 +737,7 @@ export default function CuratorPage() {
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignGenresLabel')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds.includes(g.id))
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<label
key={genre.id}
@@ -846,7 +852,7 @@ export default function CuratorPage() {
<option value="no-global">{t('filterNoGlobal')}</option>
<optgroup label="Genres">
{genres
.filter(g => curatorInfo?.genreIds.includes(g.id))
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{typeof genre.name === 'string'
@@ -857,7 +863,7 @@ export default function CuratorPage() {
</optgroup>
<optgroup label="Specials">
{specials
.filter(s => curatorInfo?.specialIds.includes(s.id))
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{' '}
@@ -1031,7 +1037,7 @@ export default function CuratorPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds.includes(g.id))
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<label
key={genre.id}
@@ -1068,7 +1074,7 @@ export default function CuratorPage() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres
.filter(
g => !curatorInfo?.genreIds.includes(g.id)
g => !curatorInfo?.genreIds?.includes(g.id)
)
.map(g => (
<span

View File

@@ -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 (
<div className="container">
<header className="header">
@@ -403,7 +410,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<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>
</div>
@@ -512,14 +519,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</audio>
</div>
<div style={{ marginBottom: '1rem' }}>
<div style={{ marginBottom: '1.25rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</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} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main>

View File

@@ -22,9 +22,25 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
fetch('/api/songs')
.then(res => res.json())
.then(data => setSongs(data));
fetch('/api/public-songs')
.then(res => {
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(() => {

View File

@@ -41,6 +41,7 @@
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
"theSongWas": "Das Lied war:",
"score": "Punkte",
"shareExplanation": "Teile dein Ergebnis mit Freund:innen so hilfst du, Hördle bekannter zu machen.",
"scoreBreakdown": "Punkteaufschlüsselung",
"albumCover": "Album-Cover",
"released": "Veröffentlicht",

View File

@@ -41,6 +41,7 @@
"comeBackTomorrow": "Come back tomorrow for a new song.",
"theSongWas": "The song was:",
"score": "Score",
"shareExplanation": "Share your result with friends your support helps Hördle grow.",
"scoreBreakdown": "Score Breakdown",
"albumCover": "Album Cover",
"released": "Released",

View File

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