diff --git a/app/actions.ts b/app/actions.ts index 9f9611f..1907580 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -30,3 +30,44 @@ export async function sendGotifyNotification(attempts: number, status: 'won' | ' console.error('Error sending Gotify notification:', error); } } + +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +export async function submitRating(songId: number, rating: number, genre?: string | null) { + try { + const song = await prisma.song.findUnique({ where: { id: songId } }); + if (!song) throw new Error('Song not found'); + + const newRatingCount = song.ratingCount + 1; + const newAverageRating = ((song.averageRating * song.ratingCount) + rating) / newRatingCount; + + await prisma.song.update({ + where: { id: songId }, + data: { + averageRating: newAverageRating, + ratingCount: newRatingCount, + }, + }); + + // Send Gotify notification for the rating + const genreText = genre ? `[${genre}] ` : ''; + const title = `Hördle Rating: ${rating} Stars`; + const message = `Song "${song.title}" by ${song.artist} ${genreText}received a ${rating}-star rating. (Avg: ${newAverageRating.toFixed(2)}, Count: ${newRatingCount})`; + + await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: title, + message: message, + priority: 5, + }), + }); + + return { success: true, averageRating: newAverageRating }; + } catch (error) { + console.error('Error submitting rating:', error); + return { success: false, error: 'Failed to submit rating' }; + } +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index a529058..9208c40 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -42,6 +42,8 @@ interface Song { puzzles: DailyPuzzle[]; genres: Genre[]; specials: Special[]; + averageRating: number; + ratingCount: number; } type SortField = 'id' | 'title' | 'artist' | 'createdAt'; @@ -1141,6 +1143,7 @@ export default function AdminPage() { Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')} Activations + Rating Actions @@ -1211,6 +1214,15 @@ export default function AdminPage() { {new Date(song.createdAt).toLocaleDateString('de-DE')} {song.activations} + + {song.averageRating > 0 ? ( + + {song.averageRating.toFixed(1)} ★ ({song.ratingCount}) + + ) : ( + - + )} +
+ {/* Rating Component */} +
+ +
+ {statistics && } + ); + })} + + + ); +} diff --git a/prisma/migrations/20251123083856_add_rating_system/migration.sql b/prisma/migrations/20251123083856_add_rating_system/migration.sql new file mode 100644 index 0000000..767adf2 --- /dev/null +++ b/prisma/migrations/20251123083856_add_rating_system/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE "Special" ADD COLUMN "curator" TEXT; + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Song" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "artist" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "coverImage" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "averageRating" REAL NOT NULL DEFAULT 0, + "ratingCount" INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO "new_Song" ("artist", "coverImage", "createdAt", "filename", "id", "title") SELECT "artist", "coverImage", "createdAt", "filename", "id", "title" FROM "Song"; +DROP TABLE "Song"; +ALTER TABLE "new_Song" RENAME TO "Song"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a709036..75ed0ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,8 @@ model Song { puzzles DailyPuzzle[] genres Genre[] specials SpecialSong[] + averageRating Float @default(0) + ratingCount Int @default(0) } model Genre { diff --git a/scripts/restore_songs.ts b/scripts/restore_songs.ts index 7404db2..753e0be 100644 --- a/scripts/restore_songs.ts +++ b/scripts/restore_songs.ts @@ -33,23 +33,33 @@ async function restoreSongs() { const title = metadata.common.title || 'Unknown Title'; const artist = metadata.common.artist || 'Unknown Artist'; + const genres = metadata.common.genre || []; - // Try to find matching cover - // This is a best-effort guess based on timestamp or just null if we can't link it easily - // Since we don't store the link between file and cover in filename, we might lose cover association - // unless we re-extract it. But we already have cover files. - // For now, let's just restore the song entry. Re-extracting cover would duplicate files. - // If the user wants covers back perfectly, we might need to re-parse or just leave null. - // Let's leave null for now to avoid clutter, or maybe try to find a cover with similar timestamp if possible? - // Actually, the cover filename is not easily deducible from song filename. - // Let's just restore the song data. + // Create or find genres + const genreConnect = []; + for (const genreName of genres) { + if (!genreName) continue; + + // Simple normalization + const normalizedGenre = genreName.trim(); + + // Upsert genre (we can't use upsert easily with connect, so find or create first) + let genre = await prisma.genre.findUnique({ where: { name: normalizedGenre } }); + if (!genre) { + genre = await prisma.genre.create({ data: { name: normalizedGenre } }); + console.log(`Created genre: ${normalizedGenre}`); + } + genreConnect.push({ id: genre.id }); + } await prisma.song.create({ data: { title, artist, filename, - // coverImage: null // We lose the cover link unfortunately, unless we re-extract + genres: { + connect: genreConnect + } } });