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