diff --git a/app/[genre]/page.tsx b/app/[genre]/page.tsx index e9904fa..4a4ad42 100644 --- a/app/[genre]/page.tsx +++ b/app/[genre]/page.tsx @@ -16,12 +16,15 @@ export default async function GenrePage({ params }: PageProps) { const decodedGenre = decodeURIComponent(genre); const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre); const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); + const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } }); return ( <>
-
+
Global + + {/* Genres */} {genres.map(g => ( ))} + + {/* Separator if both exist */} + {genres.length > 0 && specials.length > 0 && ( + | + )} + + {/* Specials */} + {specials.map(s => ( + + ★ {s.name} + + ))}
diff --git a/app/actions.ts b/app/actions.ts index d2e31ab..9f9611f 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -3,12 +3,13 @@ const GOTIFY_URL = process.env.GOTIFY_URL; const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN; -export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number) { +export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) { try { - const title = `Hördle #${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`; + const genreText = genre ? `[${genre}] ` : ''; + const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`; const message = status === 'won' - ? `Puzzle #${puzzleId} was solved in ${attempts} attempt(s).` - : `Puzzle #${puzzleId} was failed after ${attempts} attempt(s).`; + ? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).` + : `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`; const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, { method: 'POST', diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 75d08f1..9831d65 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -2,6 +2,17 @@ import { useState, useEffect } from 'react'; + +interface Special { + id: number; + name: string; + maxAttempts: number; + unlockSteps: string; + _count?: { + songs: number; + }; +} + interface Genre { id: number; name: string; @@ -18,6 +29,7 @@ interface Song { createdAt: string; activations: number; genres: Genre[]; + specials: Special[]; } type SortField = 'id' | 'title' | 'artist' | 'createdAt'; @@ -36,11 +48,22 @@ export default function AdminPage() { const [genres, setGenres] = useState([]); const [newGenreName, setNewGenreName] = useState(''); + // Specials state + const [specials, setSpecials] = useState([]); + const [newSpecialName, setNewSpecialName] = useState(''); + const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7); + const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]'); + const [editingSpecialId, setEditingSpecialId] = useState(null); + const [editSpecialName, setEditSpecialName] = useState(''); + const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7); + const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]'); + // Edit state const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editArtist, setEditArtist] = useState(''); const [editGenreIds, setEditGenreIds] = useState([]); + const [editSpecialIds, setEditSpecialIds] = useState([]); // Post-upload state const [uploadedSong, setUploadedSong] = useState(null); @@ -56,7 +79,8 @@ export default function AdminPage() { // Search and pagination state const [searchQuery, setSearchQuery] = useState(''); - const [selectedGenreFilter, setSelectedGenreFilter] = useState(null); + const [selectedGenreFilter, setSelectedGenreFilter] = useState(''); + const [selectedSpecialFilter, setSelectedSpecialFilter] = useState(null); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; @@ -119,6 +143,79 @@ export default function AdminPage() { } }; + // Specials functions + const fetchSpecials = async () => { + const res = await fetch('/api/specials'); + if (res.ok) { + const data = await res.json(); + setSpecials(data); + } + }; + + const handleCreateSpecial = async (e: React.FormEvent) => { + e.preventDefault(); + const res = await fetch('/api/specials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newSpecialName, + maxAttempts: newSpecialMaxAttempts, + unlockSteps: newSpecialUnlockSteps, + }), + }); + if (res.ok) { + setNewSpecialName(''); + setNewSpecialMaxAttempts(7); + setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]'); + fetchSpecials(); + } else { + alert('Failed to create special'); + } + }; + + const handleDeleteSpecial = async (id: number) => { + if (!confirm('Delete this special?')) return; + const res = await fetch('/api/specials', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (res.ok) fetchSpecials(); + else alert('Failed to delete special'); + }; + + const startEditSpecial = (special: Special) => { + setEditingSpecialId(special.id); + setEditSpecialName(special.name); + setEditSpecialMaxAttempts(special.maxAttempts); + setEditSpecialUnlockSteps(special.unlockSteps); + }; + + const saveEditedSpecial = async () => { + if (editingSpecialId === null) return; + const res = await fetch('/api/specials', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: editingSpecialId, + name: editSpecialName, + maxAttempts: editSpecialMaxAttempts, + unlockSteps: editSpecialUnlockSteps, + }), + }); + if (res.ok) { + setEditingSpecialId(null); + fetchSpecials(); + } else { + alert('Failed to update special'); + } + }; + + // Load specials after auth + useEffect(() => { + if (isAuthenticated) fetchSpecials(); + }, [isAuthenticated]); + const deleteGenre = async (id: number) => { if (!confirm('Delete this genre?')) return; const res = await fetch('/api/genres', { @@ -322,6 +419,7 @@ export default function AdminPage() { setEditTitle(song.title); setEditArtist(song.artist); setEditGenreIds(song.genres.map(g => g.id)); + setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []); }; const cancelEditing = () => { @@ -329,6 +427,7 @@ export default function AdminPage() { setEditTitle(''); setEditArtist(''); setEditGenreIds([]); + setEditSpecialIds([]); }; const saveEditing = async (id: number) => { @@ -339,7 +438,8 @@ export default function AdminPage() { id, title: editTitle, artist: editArtist, - genreIds: editGenreIds + genreIds: editGenreIds, + specialIds: editSpecialIds }), }); @@ -424,10 +524,21 @@ export default function AdminPage() { song.artist.toLowerCase().includes(searchQuery.toLowerCase()); // Genre filter - const matchesGenre = selectedGenreFilter === null || - song.genres.some(g => g.id === selectedGenreFilter); + // Unified Filter + let matchesFilter = true; + if (selectedGenreFilter) { + if (selectedGenreFilter.startsWith('genre:')) { + const genreId = Number(selectedGenreFilter.split(':')[1]); + matchesFilter = genreId === -1 + ? song.genres.length === 0 + : song.genres.some(g => g.id === genreId); + } else if (selectedGenreFilter.startsWith('special:')) { + const specialId = Number(selectedGenreFilter.split(':')[1]); + matchesFilter = song.specials?.some(s => s.id === specialId) || false; + } + } - return matchesSearch && matchesGenre; + return matchesSearch && matchesFilter; }); const sortedSongs = [...filteredSongs].sort((a, b) => { @@ -476,6 +587,48 @@ export default function AdminPage() {

Hördle Admin Dashboard

+ {/* Special Management */} +
+

Manage Specials

+
+
+ setNewSpecialName(e.target.value)} className="form-input" required /> + setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} /> + setNewSpecialUnlockSteps(e.target.value)} className="form-input" /> + +
+
+
+ {specials.map(special => ( +
+ {special.name} ({special._count?.songs || 0}) + + +
+ ))} +
+ {editingSpecialId !== null && ( +
+

Edit Special

+
+ setEditSpecialName(e.target.value)} className="form-input" /> + setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} /> + setEditSpecialUnlockSteps(e.target.value)} className="form-input" /> + + +
+
+ )} +
+ {/* 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) ) : (