feat: Add NoGlobal feature to exclude songs from Global Daily Puzzle

This commit is contained in:
Hördle Bot
2025-11-24 20:23:07 +01:00
7 changed files with 174 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
interface Special {
@@ -47,6 +47,7 @@ interface Song {
specials: Special[];
averageRating: number;
ratingCount: number;
excludeFromGlobal: boolean;
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
@@ -95,10 +96,12 @@ export default function AdminPage() {
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
// Post-upload state
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false);
// AI Categorization state
const [isCategorizing, setIsCategorizing] = useState(false);
@@ -123,6 +126,7 @@ export default function AdminPage() {
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Check for existing auth on mount
useEffect(() => {
@@ -478,8 +482,11 @@ export default function AdminPage() {
setUploadProgress({ current: i + 1, total: files.length });
try {
console.log(`Uploading file ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
const formData = new FormData();
formData.append('file', file);
formData.append('excludeFromGlobal', String(uploadExcludeFromGlobal));
const res = await fetch('/api/songs', {
method: 'POST',
@@ -487,8 +494,11 @@ export default function AdminPage() {
body: formData,
});
console.log(`Response status for ${file.name}: ${res.status}`);
if (res.ok) {
const data = await res.json();
console.log(`Upload successful for ${file.name}:`, data);
results.push({
filename: file.name,
success: true,
@@ -498,6 +508,7 @@ export default function AdminPage() {
} else if (res.status === 409) {
// Duplicate detected
const data = await res.json();
console.log(`Duplicate detected for ${file.name}:`, data);
results.push({
filename: file.name,
success: false,
@@ -506,17 +517,20 @@ export default function AdminPage() {
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
});
} else {
const errorText = await res.text();
console.error(`Upload failed for ${file.name} (${res.status}):`, errorText);
results.push({
filename: file.name,
success: false,
error: 'Upload failed'
error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`
});
}
} catch (error) {
console.error(`Network error for ${file.name}:`, error);
results.push({
filename: file.name,
success: false,
error: 'Network error'
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
}
@@ -553,32 +567,81 @@ export default function AdminPage() {
}
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (!isDragging) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Prevent flickering when dragging over children
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return;
}
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter(
file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3')
);
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles(droppedFiles);
// Validate file types
const validFiles: File[] = [];
const invalidFiles: string[] = [];
droppedFiles.forEach(file => {
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
validFiles.push(file);
} else {
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
}
});
if (invalidFiles.length > 0) {
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
}
if (validFiles.length > 0) {
setFiles(validFiles);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
const selectedFiles = Array.from(e.target.files);
// Validate file types
const validFiles: File[] = [];
const invalidFiles: string[] = [];
selectedFiles.forEach(file => {
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
validFiles.push(file);
} else {
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
}
});
if (invalidFiles.length > 0) {
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
}
if (validFiles.length > 0) {
setFiles(validFiles);
}
}
};
@@ -615,6 +678,7 @@ export default function AdminPage() {
setEditReleaseYear(song.releaseYear || '');
setEditGenreIds(song.genres.map(g => g.id));
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
setEditExcludeFromGlobal(song.excludeFromGlobal || false);
};
const cancelEditing = () => {
@@ -624,6 +688,7 @@ export default function AdminPage() {
setEditReleaseYear('');
setEditGenreIds([]);
setEditSpecialIds([]);
setEditExcludeFromGlobal(false);
};
const saveEditing = async (id: number) => {
@@ -636,7 +701,8 @@ export default function AdminPage() {
artist: editArtist,
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
genreIds: editGenreIds,
specialIds: editSpecialIds
specialIds: editSpecialIds,
excludeFromGlobal: editExcludeFromGlobal
}),
});
@@ -739,6 +805,8 @@ export default function AdminPage() {
} else if (selectedGenreFilter === 'daily') {
const today = new Date().toISOString().split('T')[0];
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
} else if (selectedGenreFilter === 'no-global') {
matchesFilter = song.excludeFromGlobal === true;
}
}
@@ -1052,6 +1120,7 @@ export default function AdminPage() {
<form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -1065,7 +1134,7 @@ export default function AdminPage() {
cursor: 'pointer',
transition: 'all 0.2s'
}}
onClick={() => document.getElementById('file-input')?.click()}
onClick={() => fileInputRef.current?.click()}
>
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
@@ -1075,7 +1144,7 @@ export default function AdminPage() {
or click to browse
</p>
<input
id="file-input"
ref={fileInputRef}
type="file"
accept="audio/mpeg"
multiple
@@ -1115,6 +1184,21 @@ export default function AdminPage() {
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={uploadExcludeFromGlobal}
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
style={{ width: '1.25rem', height: '1.25rem' }}
/>
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
</label>
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
If checked, these songs will only appear in Genre or Special puzzles.
</p>
</div>
<button
type="submit"
className="btn-primary"
@@ -1235,6 +1319,7 @@ export default function AdminPage() {
>
<option value="">All Content</option>
<option value="daily">📅 Song of the Day</option>
<option value="no-global">🚫 No Global</option>
<optgroup label="Genres">
<option value="genre:-1">No Genre</option>
{genres.map(genre => (
@@ -1377,6 +1462,16 @@ export default function AdminPage() {
</label>
))}
</div>
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
<input
type="checkbox"
checked={editExcludeFromGlobal}
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
/>
Exclude from Global
</label>
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -1416,6 +1511,24 @@ export default function AdminPage() {
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
{song.excludeFromGlobal && (
<div style={{ marginTop: '0.25rem' }}>
<span style={{
background: '#fee2e2',
color: '#991b1b',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem',
border: '1px solid #fecaca',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem'
}}>
🚫 No Global
</span>
</div>
)}
{/* Daily Puzzle Badges */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {

View File

@@ -8,6 +8,10 @@ import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
// Configure route to handle large file uploads
export const runtime = 'nodejs';
export const maxDuration = 60; // 60 seconds timeout for uploads
export async function GET() {
const songs = await prisma.song.findMany({
orderBy: { createdAt: 'desc' },
@@ -37,23 +41,35 @@ export async function GET() {
specials: song.specials.map(ss => ss.special),
averageRating: song.averageRating,
ratingCount: song.ratingCount,
excludeFromGlobal: song.excludeFromGlobal,
}));
return NextResponse.json(songsWithActivations);
}
export async function POST(request: Request) {
console.log('[UPLOAD] Starting song upload request');
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
if (authError) {
console.log('[UPLOAD] Authentication failed');
return authError;
}
try {
console.log('[UPLOAD] Parsing form data...');
const formData = await request.formData();
const file = formData.get('file') as File;
let title = '';
let artist = '';
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
if (!file) {
console.error('[UPLOAD] No file provided');
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
@@ -81,6 +97,7 @@ export async function POST(request: Request) {
}
const buffer = Buffer.from(await file.arrayBuffer());
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
// Validate and extract metadata from file
let metadata;
@@ -208,10 +225,9 @@ export async function POST(request: Request) {
console.error('Failed to extract cover image:', e);
}
// Fetch release year (iTunes first, then MusicBrainz)
// Fetch release year from iTunes
let releaseYear = null;
try {
// Try iTunes first
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
releaseYear = await getReleaseYearFromItunes(artist, title);
@@ -229,6 +245,7 @@ export async function POST(request: Request) {
filename,
coverImage,
releaseYear,
excludeFromGlobal,
},
include: { genres: true, specials: true }
});
@@ -249,7 +266,7 @@ export async function PUT(request: Request) {
if (authError) return authError;
try {
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json();
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
@@ -262,6 +279,10 @@ export async function PUT(request: Request) {
data.releaseYear = releaseYear;
}
if (excludeFromGlobal !== undefined) {
data.excludeFromGlobal = excludeFromGlobal;
}
if (genreIds) {
data.genres = {
set: genreIds.map((gId: number) => ({ id: gId }))

View File

@@ -33,7 +33,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
// Get songs available for this genre
const whereClause = genreId
? { genres: { some: { id: genreId } } }
: {}; // Global puzzle picks from ALL songs
: { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
const allSongs = await prisma.song.findMany({
where: whereClause,

View File

@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
serverActions: {
bodySizeLimit: '50mb',
},
middlewareClientMaxBodySize: '50mb',
},
env: {
TZ: process.env.TZ || 'Europe/Berlin',

BIN
prisma/dev.db.bak Normal file

Binary file not shown.

View File

@@ -0,0 +1,20 @@
-- 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,
"releaseYear" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"averageRating" REAL NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"excludeFromGlobal" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Song" ("artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title") SELECT "artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title" FROM "Song";
DROP TABLE "Song";
ALTER TABLE "new_Song" RENAME TO "Song";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -23,6 +23,7 @@ model Song {
specials SpecialSong[]
averageRating Float @default(0)
ratingCount Int @default(0)
excludeFromGlobal Boolean @default(false)
}
model Genre {