From 8c720e287ff21b79a9e74c842e0044415b606c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Sat, 22 Nov 2025 11:56:16 +0100 Subject: [PATCH] Feat: Genre system with per-genre daily puzzles --- app/[genre]/page.tsx | 43 +++++++++++++++ app/api/daily/route.ts | 76 +++------------------------ app/api/genres/route.ts | 59 +++++++++++++++++++++ app/api/songs/route.ts | 16 +++++- app/page.tsx | 90 +++++++------------------------- components/Game.tsx | 12 +++-- lib/dailyPuzzle.ts | 113 ++++++++++++++++++++++++++++++++++++++++ lib/gameState.ts | 26 +++++---- prisma/schema.prisma | 14 ++++- 9 files changed, 294 insertions(+), 155 deletions(-) create mode 100644 app/[genre]/page.tsx create mode 100644 app/api/genres/route.ts create mode 100644 lib/dailyPuzzle.ts diff --git a/app/[genre]/page.tsx b/app/[genre]/page.tsx new file mode 100644 index 0000000..e9904fa --- /dev/null +++ b/app/[genre]/page.tsx @@ -0,0 +1,43 @@ +import Game from '@/components/Game'; +import { getOrCreateDailyPuzzle } 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<{ genre: string }>; +} + +export default async function GenrePage({ params }: PageProps) { + const { genre } = await params; + const decodedGenre = decodeURIComponent(genre); + const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre); + const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); + + return ( + <> +
+
+ Global + {genres.map(g => ( + + {g.name} + + ))} +
+
+ + + ); +} diff --git a/app/api/daily/route.ts b/app/api/daily/route.ts index 0c62d09..09e21b9 100644 --- a/app/api/daily/route.ts +++ b/app/api/daily/route.ts @@ -1,78 +1,18 @@ import { NextResponse } from 'next/server'; -import { PrismaClient } from '@prisma/client'; -import { getTodayISOString } from '@/lib/dateUtils'; +import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle'; -const prisma = new PrismaClient(); - -export async function GET() { +export async function GET(request: Request) { try { - const today = getTodayISOString(); + const { searchParams } = new URL(request.url); + const genreName = searchParams.get('genre'); - let dailyPuzzle = await prisma.dailyPuzzle.findUnique({ - where: { date: today }, - include: { song: true }, - }); + const puzzle = await getOrCreateDailyPuzzle(genreName); - console.log(`[Daily Puzzle] Date: ${today}, Found existing: ${!!dailyPuzzle}`); - - if (!dailyPuzzle) { - // Get all songs with their usage count - const allSongs = await prisma.song.findMany({ - include: { - puzzles: true, - }, - }); - - if (allSongs.length === 0) { - return NextResponse.json({ error: 'No songs available' }, { status: 404 }); - } - - // Calculate weights: songs never used get weight 1.0, - // songs used once get 0.5, twice 0.33, etc. - const weightedSongs = allSongs.map(song => ({ - song, - weight: 1.0 / (song.puzzles.length + 1), - })); - - // Calculate total weight - const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0); - - // Pick a random song based on weights - let random = Math.random() * totalWeight; - let selectedSong = weightedSongs[0].song; - - for (const item of weightedSongs) { - random -= item.weight; - if (random <= 0) { - selectedSong = item.song; - break; - } - } - - // Create the daily puzzle - dailyPuzzle = await prisma.dailyPuzzle.create({ - data: { - date: today, - songId: selectedSong.id, - }, - include: { song: true }, - }); - - console.log(`[Daily Puzzle] Created new puzzle for ${today} with song: ${selectedSong.title} (ID: ${selectedSong.id})`); + if (!puzzle) { + return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 }); } - if (!dailyPuzzle) { - return NextResponse.json({ error: 'Failed to create puzzle' }, { status: 500 }); - } - - return NextResponse.json({ - id: dailyPuzzle.id, - audioUrl: `/uploads/${dailyPuzzle.song.filename}`, - songId: dailyPuzzle.songId, - title: dailyPuzzle.song.title, - artist: dailyPuzzle.song.artist, - coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null, - }); + return NextResponse.json(puzzle); } catch (error) { console.error('Error fetching daily puzzle:', error); diff --git a/app/api/genres/route.ts b/app/api/genres/route.ts new file mode 100644 index 0000000..de66d81 --- /dev/null +++ b/app/api/genres/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function GET() { + try { + const genres = await prisma.genre.findMany({ + orderBy: { name: 'asc' }, + include: { + _count: { + select: { songs: true } + } + } + }); + return NextResponse.json(genres); + } catch (error) { + console.error('Error fetching genres:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const { name } = await request.json(); + + if (!name || typeof name !== 'string') { + return NextResponse.json({ error: 'Invalid name' }, { status: 400 }); + } + + const genre = await prisma.genre.create({ + data: { name: name.trim() }, + }); + + return NextResponse.json(genre); + } catch (error) { + console.error('Error creating genre:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + try { + const { id } = await request.json(); + + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + await prisma.genre.delete({ + where: { id: Number(id) }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting genre:', 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 a5ca747..d4eb6b6 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -11,6 +11,7 @@ export async function GET() { orderBy: { createdAt: 'desc' }, include: { puzzles: true, + genres: true, }, }); @@ -23,6 +24,7 @@ export async function GET() { createdAt: song.createdAt, coverImage: song.coverImage, activations: song.puzzles.length, + genres: song.genres, })); return NextResponse.json(songsWithActivations); @@ -144,6 +146,7 @@ export async function POST(request: Request) { filename, coverImage, }, + include: { genres: true } }); return NextResponse.json({ @@ -158,15 +161,24 @@ export async function POST(request: Request) { export async function PUT(request: Request) { try { - const { id, title, artist } = await request.json(); + const { id, title, artist, genreIds } = await request.json(); if (!id || !title || !artist) { return NextResponse.json({ error: 'Missing fields' }, { status: 400 }); } + const data: any = { title, artist }; + + if (genreIds) { + data.genres = { + set: genreIds.map((gId: number) => ({ id: gId })) + }; + } + const updatedSong = await prisma.song.update({ where: { id: Number(id) }, - data: { title, artist }, + data, + include: { genres: true } }); return NextResponse.json(updatedSong); diff --git a/app/page.tsx b/app/page.tsx index 75daf04..3c70f35 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,81 +1,29 @@ import Game from '@/components/Game'; -import { getTodayISOString } from '@/lib/dateUtils'; +import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle'; +import Link from 'next/link'; +import { PrismaClient } from '@prisma/client'; export const dynamic = 'force-dynamic'; -import { PrismaClient } from '@prisma/client'; - -// PrismaClient is attached to the `global` object in development to prevent -// exhausting your database connection limit. -const globalForPrisma = global as unknown as { prisma: PrismaClient }; - -const prisma = globalForPrisma.prisma || new PrismaClient(); - -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; - -async function getDailyPuzzle() { - try { - const today = getTodayISOString(); - console.log(`[getDailyPuzzle] Checking puzzle for date: ${today}`); - - let dailyPuzzle = await prisma.dailyPuzzle.findUnique({ - where: { date: today }, - include: { song: true }, - }); - - if (!dailyPuzzle) { - console.log('[getDailyPuzzle] No puzzle found, attempting to create...'); - const songsCount = await prisma.song.count(); - console.log(`[getDailyPuzzle] Found ${songsCount} songs in DB`); - - if (songsCount > 0) { - const skip = Math.floor(Math.random() * songsCount); - const randomSong = await prisma.song.findFirst({ skip }); - - if (randomSong) { - try { - dailyPuzzle = await prisma.dailyPuzzle.create({ - data: { date: today, songId: randomSong.id }, - include: { song: true }, - }); - console.log(`[getDailyPuzzle] Created puzzle for song: ${randomSong.title}`); - } catch (createError) { - // Handle race condition: if another request created it in the meantime - console.log('[getDailyPuzzle] Creation failed, trying to fetch again (likely race condition)'); - dailyPuzzle = await prisma.dailyPuzzle.findUnique({ - where: { date: today }, - include: { song: true }, - }); - } - } - } else { - console.log('[getDailyPuzzle] No songs available to create puzzle'); - } - } - - if (!dailyPuzzle) { - console.log('[getDailyPuzzle] Failed to get or create puzzle'); - return null; - } - - return { - id: dailyPuzzle.id, - audioUrl: `/uploads/${dailyPuzzle.song.filename}`, - songId: dailyPuzzle.songId, - title: dailyPuzzle.song.title, - artist: dailyPuzzle.song.artist, - coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null, - }; - } catch (e) { - console.error('[getDailyPuzzle] Error:', e); - return null; - } -} +const prisma = new PrismaClient(); export default async function Home() { - const dailyPuzzle = await getDailyPuzzle(); + const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle + const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); return ( - + <> +
+
+ Global + {genres.map(g => ( + + {g.name} + + ))} +
+
+ + ); } diff --git a/components/Game.tsx b/components/Game.tsx index 27b190f..d0d5109 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -16,12 +16,13 @@ interface GameProps { artist: string; coverImage: string | null; } | null; + genre?: string | null; } const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60]; -export default function Game({ dailyPuzzle }: GameProps) { - const { gameState, statistics, addGuess } = useGameState(); +export default function Game({ dailyPuzzle, genre = null }: GameProps) { + const { gameState, statistics, addGuess } = useGameState(genre); const [hasWon, setHasWon] = useState(false); const [hasLost, setHasLost] = useState(false); const [shareText, setShareText] = useState('Share Result'); @@ -42,7 +43,7 @@ export default function Game({ dailyPuzzle }: GameProps) {

No Puzzle Available

Could not generate a daily puzzle.

-

Please ensure there are songs in the database.

+

Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.

Go to Admin Dashboard
); @@ -58,6 +59,7 @@ export default function Game({ dailyPuzzle }: GameProps) { addGuess(song.title, false); if (gameState.guesses.length + 1 >= 7) { setHasLost(true); + setHasWon(false); // Ensure won is false sendGotifyNotification(7, 'lost', dailyPuzzle.id); } } @@ -72,6 +74,7 @@ export default function Game({ dailyPuzzle }: GameProps) { setLastAction('SKIP'); addGuess("SKIPPED", false); setHasLost(true); + setHasWon(false); sendGotifyNotification(7, 'lost', dailyPuzzle.id); }; @@ -98,7 +101,8 @@ export default function Game({ dailyPuzzle }: GameProps) { } const speaker = hasWon ? '🔉' : '🔇'; - const text = `Hördle #${dailyPuzzle.id}\n\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`; + const genreText = genre ? `Genre: ${genre}\n` : ''; + const text = `Hördle #${dailyPuzzle.id}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`; // Fallback method for copying to clipboard const textarea = document.createElement('textarea'); diff --git a/lib/dailyPuzzle.ts b/lib/dailyPuzzle.ts new file mode 100644 index 0000000..3fa85fe --- /dev/null +++ b/lib/dailyPuzzle.ts @@ -0,0 +1,113 @@ +import { PrismaClient } from '@prisma/client'; +import { getTodayISOString } from './dateUtils'; + +const prisma = new PrismaClient(); + +export async function getOrCreateDailyPuzzle(genreName: string | null = null) { + try { + const today = getTodayISOString(); + let genreId: number | null = null; + + if (genreName) { + const genre = await prisma.genre.findUnique({ + where: { name: genreName } + }); + if (genre) { + genreId = genre.id; + } else { + return null; // Genre not found + } + } + + let dailyPuzzle = await prisma.dailyPuzzle.findFirst({ + where: { + date: today, + genreId: genreId + }, + include: { song: true }, + }); + + console.log(`[Daily Puzzle] Date: ${today}, Genre: ${genreName || 'Global'}, Found existing: ${!!dailyPuzzle}`); + + if (!dailyPuzzle) { + // Get songs available for this genre + const whereClause = genreId + ? { genres: { some: { id: genreId } } } + : {}; // Global puzzle picks from ALL songs + + const allSongs = await prisma.song.findMany({ + where: whereClause, + include: { + puzzles: { + where: { genreId: genreId } + }, + }, + }); + + if (allSongs.length === 0) { + console.log(`[Daily Puzzle] No songs available for genre: ${genreName || 'Global'}`); + return null; + } + + // Calculate weights + const weightedSongs = allSongs.map(song => ({ + song, + weight: 1.0 / (song.puzzles.length + 1), + })); + + // Calculate total weight + const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0); + + // Pick a random song based on weights + let random = Math.random() * totalWeight; + let selectedSong = weightedSongs[0].song; + + for (const item of weightedSongs) { + random -= item.weight; + if (random <= 0) { + selectedSong = item.song; + break; + } + } + + // Create the daily puzzle + try { + dailyPuzzle = await prisma.dailyPuzzle.create({ + data: { + date: today, + songId: selectedSong.id, + genreId: genreId + }, + include: { song: true }, + }); + console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genreName || 'Global'}) with song: ${selectedSong.title}`); + } catch (e) { + // Handle race condition + console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)'); + dailyPuzzle = await prisma.dailyPuzzle.findFirst({ + where: { + date: today, + genreId: genreId + }, + include: { song: true }, + }); + } + } + + if (!dailyPuzzle) return null; + + return { + id: dailyPuzzle.id, + audioUrl: `/uploads/${dailyPuzzle.song.filename}`, + songId: dailyPuzzle.songId, + title: dailyPuzzle.song.title, + artist: dailyPuzzle.song.artist, + coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null, + genre: genreName + }; + + } catch (error) { + console.error('Error in getOrCreateDailyPuzzle:', error); + return null; + } +} diff --git a/lib/gameState.ts b/lib/gameState.ts index 90f9690..3aee5b4 100644 --- a/lib/gameState.ts +++ b/lib/gameState.ts @@ -25,13 +25,20 @@ export interface Statistics { const STORAGE_KEY = 'hoerdle_game_state'; const STATS_KEY = 'hoerdle_statistics'; -export function useGameState() { +export function useGameState(genre: string | null = null) { const [gameState, setGameState] = useState(null); const [statistics, setStatistics] = useState(null); + const STORAGE_KEY_PREFIX = 'hoerdle_game_state'; + const STATS_KEY_PREFIX = 'hoerdle_statistics'; + + const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX; + const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX; + useEffect(() => { // Load game state - const stored = localStorage.getItem(STORAGE_KEY); + const storageKey = getStorageKey(); + const stored = localStorage.getItem(storageKey); const today = getTodayISOString(); if (stored) { @@ -48,7 +55,7 @@ export function useGameState() { lastPlayed: Date.now(), }; setGameState(newState); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + localStorage.setItem(storageKey, JSON.stringify(newState)); } } else { // No state @@ -60,11 +67,12 @@ export function useGameState() { lastPlayed: Date.now(), }; setGameState(newState); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + localStorage.setItem(storageKey, JSON.stringify(newState)); } // Load statistics - const storedStats = localStorage.getItem(STATS_KEY); + const statsKey = getStatsKey(); + const storedStats = localStorage.getItem(statsKey); if (storedStats) { const parsedStats = JSON.parse(storedStats); // Migration for existing stats without solvedIn7 @@ -84,13 +92,13 @@ export function useGameState() { failed: 0, }; setStatistics(newStats); - localStorage.setItem(STATS_KEY, JSON.stringify(newStats)); + localStorage.setItem(statsKey, JSON.stringify(newStats)); } - }, []); + }, [genre]); // Re-run when genre changes const saveState = (newState: GameState) => { setGameState(newState); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + localStorage.setItem(getStorageKey(), JSON.stringify(newState)); }; const updateStatistics = (attempts: number, solved: boolean) => { @@ -113,7 +121,7 @@ export function useGameState() { } setStatistics(newStats); - localStorage.setItem(STATS_KEY, JSON.stringify(newStats)); + localStorage.setItem(getStatsKey(), JSON.stringify(newStats)); }; const addGuess = (guess: string, correct: boolean) => { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index debe4c9..c62009a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,11 +18,23 @@ model Song { coverImage String? // Filename in public/uploads/covers createdAt DateTime @default(now()) puzzles DailyPuzzle[] + genres Genre[] +} + +model Genre { + id Int @id @default(autoincrement()) + name String @unique + songs Song[] + dailyPuzzles DailyPuzzle[] } model DailyPuzzle { id Int @id @default(autoincrement()) - date String @unique // Format: YYYY-MM-DD + date String // Format: YYYY-MM-DD songId Int song Song @relation(fields: [songId], references: [id]) + genreId Int? + genre Genre? @relation(fields: [genreId], references: [id]) + + @@unique([date, genreId]) // Unique puzzle per date per genre (null genreId = global puzzle) }