Feat: Genre system with per-genre daily puzzles
This commit is contained in:
43
app/[genre]/page.tsx
Normal file
43
app/[genre]/page.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
||||
{genres.map(g => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${g.name}`}
|
||||
style={{
|
||||
fontWeight: g.name === decodedGenre ? 'bold' : 'normal',
|
||||
textDecoration: g.name === decodedGenre ? 'underline' : 'none',
|
||||
color: g.name === decodedGenre ? 'black' : '#4b5563'
|
||||
}}
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
59
app/api/genres/route.ts
Normal file
59
app/api/genres/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
90
app/page.tsx
90
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 (
|
||||
<Game dailyPuzzle={dailyPuzzle} />
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
||||
{genres.map(g => (
|
||||
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user