Hördle Admin Dashboard
+ {/* Special Management */}
+
+
Manage Specials
+
+
+ {specials.map(special => (
+
+ {special.name} ({special._count?.songs || 0})
+
+
+
+ ))}
+
+ {editingSpecialId !== null && (
+
+ )}
+
+
{/* Genre Management */}
Manage Genres
@@ -704,23 +857,33 @@ export default function AdminPage() {
style={{ flex: '1', minWidth: '200px' }}
/>
{(searchQuery || selectedGenreFilter) && (
+
+ {specials.map(special => (
+
+ ))}
+
{new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -854,6 +1035,19 @@ export default function AdminPage() {
))}
+
+ {song.specials?.map(s => (
+
+ {s.name}
+
+ ))}
+
|
{new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -934,6 +1128,47 @@ export default function AdminPage() {
)}
+
+
+
+ Danger Zone
+
+
+ These actions are destructive and cannot be undone.
+
+
+
);
}
diff --git a/app/api/admin/rebuild/route.ts b/app/api/admin/rebuild/route.ts
new file mode 100644
index 0000000..4bf169f
--- /dev/null
+++ b/app/api/admin/rebuild/route.ts
@@ -0,0 +1,95 @@
+import { NextResponse } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+import { parseFile } from 'music-metadata';
+import path from 'path';
+import fs from 'fs/promises';
+
+const prisma = new PrismaClient();
+
+export async function POST() {
+ try {
+ console.log('[Rebuild] Starting database rebuild...');
+
+ // 1. Clear Database
+ // Delete in order to respect foreign keys
+ await prisma.dailyPuzzle.deleteMany();
+ // We need to clear the many-to-many relations first implicitly by deleting songs/genres/specials
+ // But explicit deletion of join tables isn't needed with Prisma's cascading deletes usually,
+ // but let's be safe and delete main entities.
+ await prisma.song.deleteMany();
+ await prisma.genre.deleteMany();
+ await prisma.special.deleteMany();
+
+ console.log('[Rebuild] Database cleared.');
+
+ // 2. Clear Covers Directory
+ const coversDir = path.join(process.cwd(), 'public/uploads/covers');
+ try {
+ const coverFiles = await fs.readdir(coversDir);
+ for (const file of coverFiles) {
+ if (file !== '.gitkeep') { // Preserve .gitkeep if it exists
+ await fs.unlink(path.join(coversDir, file));
+ }
+ }
+ console.log('[Rebuild] Covers directory cleared.');
+ } catch (e) {
+ console.log('[Rebuild] Covers directory might not exist or empty, creating it.');
+ await fs.mkdir(coversDir, { recursive: true });
+ }
+
+ // 3. Re-import Songs
+ const uploadsDir = path.join(process.cwd(), 'public/uploads');
+ const files = await fs.readdir(uploadsDir);
+ const mp3Files = files.filter(f => f.endsWith('.mp3'));
+
+ console.log(`[Rebuild] Found ${mp3Files.length} MP3 files to import.`);
+
+ let importedCount = 0;
+
+ for (const filename of mp3Files) {
+ const filePath = path.join(uploadsDir, filename);
+
+ try {
+ const metadata = await parseFile(filePath);
+
+ const title = metadata.common.title || 'Unknown Title';
+ const artist = metadata.common.artist || 'Unknown Artist';
+
+ let coverImage = null;
+ const picture = metadata.common.picture?.[0];
+
+ if (picture) {
+ const extension = picture.format.split('/')[1] || 'jpg';
+ const coverFilename = `cover-${Date.now()}-${Math.random().toString(36).substring(7)}.${extension}`;
+ const coverPath = path.join(coversDir, coverFilename);
+
+ await fs.writeFile(coverPath, picture.data);
+ coverImage = coverFilename;
+ }
+
+ await prisma.song.create({
+ data: {
+ title,
+ artist,
+ filename,
+ coverImage
+ }
+ });
+ importedCount++;
+ } catch (e) {
+ console.error(`[Rebuild] Failed to process ${filename}:`, e);
+ }
+ }
+
+ console.log(`[Rebuild] Successfully imported ${importedCount} songs.`);
+
+ return NextResponse.json({
+ success: true,
+ message: `Database rebuilt. Imported ${importedCount} songs.`
+ });
+
+ } catch (error) {
+ console.error('[Rebuild] Error:', error);
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+}
diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts
index d4eb6b6..555cdf5 100644
--- a/app/api/songs/route.ts
+++ b/app/api/songs/route.ts
@@ -12,6 +12,7 @@ export async function GET() {
include: {
puzzles: true,
genres: true,
+ specials: true,
},
});
@@ -25,6 +26,7 @@ export async function GET() {
coverImage: song.coverImage,
activations: song.puzzles.length,
genres: song.genres,
+ specials: song.specials,
}));
return NextResponse.json(songsWithActivations);
@@ -146,7 +148,7 @@ export async function POST(request: Request) {
filename,
coverImage,
},
- include: { genres: true }
+ include: { genres: true, specials: true }
});
return NextResponse.json({
@@ -161,7 +163,7 @@ export async function POST(request: Request) {
export async function PUT(request: Request) {
try {
- const { id, title, artist, genreIds } = await request.json();
+ const { id, title, artist, genreIds, specialIds } = await request.json();
if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
@@ -175,10 +177,16 @@ export async function PUT(request: Request) {
};
}
+ if (specialIds) {
+ data.specials = {
+ set: specialIds.map((sId: number) => ({ id: sId }))
+ };
+ }
+
const updatedSong = await prisma.song.update({
where: { id: Number(id) },
data,
- include: { genres: true }
+ include: { genres: true, specials: true }
});
return NextResponse.json(updatedSong);
diff --git a/app/api/specials/route.ts b/app/api/specials/route.ts
new file mode 100644
index 0000000..e546a8a
--- /dev/null
+++ b/app/api/specials/route.ts
@@ -0,0 +1,51 @@
+import { PrismaClient, Special } from '@prisma/client';
+import { NextResponse } from 'next/server';
+
+const prisma = new PrismaClient();
+
+export async function GET() {
+ const specials = await prisma.special.findMany({
+ orderBy: { name: 'asc' },
+ });
+ return NextResponse.json(specials);
+}
+
+export async function POST(request: Request) {
+ const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]' } = await request.json();
+ if (!name) {
+ return NextResponse.json({ error: 'Name is required' }, { status: 400 });
+ }
+ const special = await prisma.special.create({
+ data: {
+ name,
+ maxAttempts: Number(maxAttempts),
+ unlockSteps,
+ },
+ });
+ return NextResponse.json(special);
+}
+
+export async function DELETE(request: Request) {
+ const { id } = await request.json();
+ if (!id) {
+ return NextResponse.json({ error: 'ID required' }, { status: 400 });
+ }
+ await prisma.special.delete({ where: { id: Number(id) } });
+ return NextResponse.json({ success: true });
+}
+
+export async function PUT(request: Request) {
+ const { id, name, maxAttempts, unlockSteps } = await request.json();
+ if (!id) {
+ return NextResponse.json({ error: 'ID required' }, { status: 400 });
+ }
+ const updated = await prisma.special.update({
+ where: { id: Number(id) },
+ data: {
+ ...(name && { name }),
+ ...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
+ ...(unlockSteps && { unlockSteps }),
+ },
+ });
+ return NextResponse.json(updated);
+}
diff --git a/app/page.tsx b/app/page.tsx
index 3c70f35..e0b68a9 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -10,17 +10,40 @@ const prisma = new PrismaClient();
export default async function Home() {
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
+ const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
return (
<>
-
+
Global
+
+ {/* Genres */}
{genres.map(g => (
{g.name}
))}
+
+ {/* Separator if both exist */}
+ {genres.length > 0 && specials.length > 0 && (
+ |
+ )}
+
+ {/* Specials */}
+ {specials.map(s => (
+
+ ★ {s.name}
+
+ ))}
diff --git a/app/special/[name]/page.tsx b/app/special/[name]/page.tsx
new file mode 100644
index 0000000..6ecd405
--- /dev/null
+++ b/app/special/[name]/page.tsx
@@ -0,0 +1,70 @@
+import Game from '@/components/Game';
+import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
+import Link from 'next/link';
+import { PrismaClient } from '@prisma/client';
+
+export const dynamic = 'force-dynamic';
+
+const prisma = new PrismaClient();
+
+interface PageProps {
+ params: Promise<{ name: string }>;
+}
+
+export default async function SpecialPage({ params }: PageProps) {
+ const { name } = await params;
+ const decodedName = decodeURIComponent(name);
+ const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
+ const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
+ const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
+
+ return (
+ <>
+
+
+ Global
+
+ {/* Genres */}
+ {genres.map(g => (
+
+ {g.name}
+
+ ))}
+
+ {/* Separator if both exist */}
+ {genres.length > 0 && specials.length > 0 && (
+ |
+ )}
+
+ {/* Specials */}
+ {specials.map(s => (
+
+ ★ {s.name}
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/components/Game.tsx b/components/Game.tsx
index 4d4fa46..d4bc826 100644
--- a/components/Game.tsx
+++ b/components/Game.tsx
@@ -17,12 +17,14 @@ interface GameProps {
coverImage: string | null;
} | null;
genre?: string | null;
+ maxAttempts?: number;
+ unlockSteps?: number[];
}
-const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
+const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
-export default function Game({ dailyPuzzle, genre = null }: GameProps) {
- const { gameState, statistics, addGuess } = useGameState(genre);
+export default function Game({ dailyPuzzle, genre = null, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
+ const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('Share Result');
@@ -54,13 +56,13 @@ export default function Game({ dailyPuzzle, genre = null }: GameProps) {
if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true);
setHasWon(true);
- sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id);
+ sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre);
} else {
addGuess(song.title, false);
- if (gameState.guesses.length + 1 >= 7) {
+ if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false); // Ensure won is false
- sendGotifyNotification(7, 'lost', dailyPuzzle.id);
+ sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
}
}
};
@@ -75,14 +77,14 @@ export default function Game({ dailyPuzzle, genre = null }: GameProps) {
addGuess("SKIPPED", false);
setHasLost(true);
setHasWon(false);
- sendGotifyNotification(7, 'lost', dailyPuzzle.id);
+ sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
};
- const unlockedSeconds = UNLOCK_STEPS[Math.min(gameState.guesses.length, 6)];
+ const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
const handleShare = () => {
let emojiGrid = '';
- const totalGuesses = 7;
+ const totalGuesses = maxAttempts;
// Build the grid
for (let i = 0; i < totalGuesses; i++) {
@@ -135,7 +137,7 @@ export default function Game({ dailyPuzzle, genre = null }: GameProps) {
- Attempt {gameState.guesses.length + 1} / 7
+ Attempt {gameState.guesses.length + 1} / {maxAttempts}
{unlockedSeconds}s unlocked
- Skip (+{UNLOCK_STEPS[Math.min(gameState.guesses.length + 1, 6)] - unlockedSeconds}s)
+ Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
) : (
|