Implement Specials feature, Admin UI enhancements, and Database Rebuild tool

This commit is contained in:
Hördle Bot
2025-11-22 16:09:45 +01:00
parent c270f2098f
commit 903d626699
16 changed files with 816 additions and 37 deletions

View File

@@ -16,12 +16,15 @@ export default async function GenrePage({ params }: PageProps) {
const decodedGenre = decodeURIComponent(genre); const decodedGenre = decodeURIComponent(genre);
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre); const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
return ( return (
<> <>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}> <div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link> <Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
{/* Genres */}
{genres.map(g => ( {genres.map(g => (
<Link <Link
key={g.id} key={g.id}
@@ -35,6 +38,26 @@ export default async function GenrePage({ params }: PageProps) {
{g.name} {g.name}
</Link> </Link>
))} ))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
))}
</div> </div>
</div> </div>
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} /> <Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />

View File

@@ -3,12 +3,13 @@
const GOTIFY_URL = process.env.GOTIFY_URL; const GOTIFY_URL = process.env.GOTIFY_URL;
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN; const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number) { export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) {
try { try {
const title = `Hördle #${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`; const genreText = genre ? `[${genre}] ` : '';
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
const message = status === 'won' const message = status === 'won'
? `Puzzle #${puzzleId} was solved in ${attempts} attempt(s).` ? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).`
: `Puzzle #${puzzleId} was failed after ${attempts} attempt(s).`; : `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`;
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, { const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST', method: 'POST',

View File

@@ -2,6 +2,17 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
interface Special {
id: number;
name: string;
maxAttempts: number;
unlockSteps: string;
_count?: {
songs: number;
};
}
interface Genre { interface Genre {
id: number; id: number;
name: string; name: string;
@@ -18,6 +29,7 @@ interface Song {
createdAt: string; createdAt: string;
activations: number; activations: number;
genres: Genre[]; genres: Genre[];
specials: Special[];
} }
type SortField = 'id' | 'title' | 'artist' | 'createdAt'; type SortField = 'id' | 'title' | 'artist' | 'createdAt';
@@ -36,11 +48,22 @@ export default function AdminPage() {
const [genres, setGenres] = useState<Genre[]>([]); const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState(''); const [newGenreName, setNewGenreName] = useState('');
// Specials state
const [specials, setSpecials] = useState<Special[]>([]);
const [newSpecialName, setNewSpecialName] = useState('');
const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7);
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
const [editSpecialName, setEditSpecialName] = useState('');
const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7);
const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
// Edit state // Edit state
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState(''); const [editArtist, setEditArtist] = useState('');
const [editGenreIds, setEditGenreIds] = useState<number[]>([]); const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
// Post-upload state // Post-upload state
const [uploadedSong, setUploadedSong] = useState<Song | null>(null); const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
@@ -56,7 +79,8 @@ export default function AdminPage() {
// Search and pagination state // Search and pagination state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedGenreFilter, setSelectedGenreFilter] = useState<number | null>(null); const [selectedGenreFilter, setSelectedGenreFilter] = useState<string>('');
const [selectedSpecialFilter, setSelectedSpecialFilter] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10; const itemsPerPage = 10;
@@ -119,6 +143,79 @@ export default function AdminPage() {
} }
}; };
// Specials functions
const fetchSpecials = async () => {
const res = await fetch('/api/specials');
if (res.ok) {
const data = await res.json();
setSpecials(data);
}
};
const handleCreateSpecial = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/specials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newSpecialName,
maxAttempts: newSpecialMaxAttempts,
unlockSteps: newSpecialUnlockSteps,
}),
});
if (res.ok) {
setNewSpecialName('');
setNewSpecialMaxAttempts(7);
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
fetchSpecials();
} else {
alert('Failed to create special');
}
};
const handleDeleteSpecial = async (id: number) => {
if (!confirm('Delete this special?')) return;
const res = await fetch('/api/specials', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) fetchSpecials();
else alert('Failed to delete special');
};
const startEditSpecial = (special: Special) => {
setEditingSpecialId(special.id);
setEditSpecialName(special.name);
setEditSpecialMaxAttempts(special.maxAttempts);
setEditSpecialUnlockSteps(special.unlockSteps);
};
const saveEditedSpecial = async () => {
if (editingSpecialId === null) return;
const res = await fetch('/api/specials', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingSpecialId,
name: editSpecialName,
maxAttempts: editSpecialMaxAttempts,
unlockSteps: editSpecialUnlockSteps,
}),
});
if (res.ok) {
setEditingSpecialId(null);
fetchSpecials();
} else {
alert('Failed to update special');
}
};
// Load specials after auth
useEffect(() => {
if (isAuthenticated) fetchSpecials();
}, [isAuthenticated]);
const deleteGenre = async (id: number) => { const deleteGenre = async (id: number) => {
if (!confirm('Delete this genre?')) return; if (!confirm('Delete this genre?')) return;
const res = await fetch('/api/genres', { const res = await fetch('/api/genres', {
@@ -322,6 +419,7 @@ export default function AdminPage() {
setEditTitle(song.title); setEditTitle(song.title);
setEditArtist(song.artist); setEditArtist(song.artist);
setEditGenreIds(song.genres.map(g => g.id)); setEditGenreIds(song.genres.map(g => g.id));
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
}; };
const cancelEditing = () => { const cancelEditing = () => {
@@ -329,6 +427,7 @@ export default function AdminPage() {
setEditTitle(''); setEditTitle('');
setEditArtist(''); setEditArtist('');
setEditGenreIds([]); setEditGenreIds([]);
setEditSpecialIds([]);
}; };
const saveEditing = async (id: number) => { const saveEditing = async (id: number) => {
@@ -339,7 +438,8 @@ export default function AdminPage() {
id, id,
title: editTitle, title: editTitle,
artist: editArtist, artist: editArtist,
genreIds: editGenreIds genreIds: editGenreIds,
specialIds: editSpecialIds
}), }),
}); });
@@ -424,10 +524,21 @@ export default function AdminPage() {
song.artist.toLowerCase().includes(searchQuery.toLowerCase()); song.artist.toLowerCase().includes(searchQuery.toLowerCase());
// Genre filter // Genre filter
const matchesGenre = selectedGenreFilter === null || // Unified Filter
song.genres.some(g => g.id === selectedGenreFilter); let matchesFilter = true;
if (selectedGenreFilter) {
if (selectedGenreFilter.startsWith('genre:')) {
const genreId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = genreId === -1
? song.genres.length === 0
: song.genres.some(g => g.id === genreId);
} else if (selectedGenreFilter.startsWith('special:')) {
const specialId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
}
}
return matchesSearch && matchesGenre; return matchesSearch && matchesFilter;
}); });
const sortedSongs = [...filteredSongs].sort((a, b) => { const sortedSongs = [...filteredSongs].sort((a, b) => {
@@ -476,6 +587,48 @@ export default function AdminPage() {
<div className="admin-container"> <div className="admin-container">
<h1 className="title" style={{ marginBottom: '2rem' }}>Hördle Admin Dashboard</h1> <h1 className="title" style={{ marginBottom: '2rem' }}>Hördle Admin Dashboard</h1>
{/* Special Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Specials</h2>
<form onSubmit={handleCreateSpecial} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input type="text" placeholder="Special name" value={newSpecialName} onChange={e => setNewSpecialName(e.target.value)} className="form-input" required />
<input type="number" placeholder="Max attempts" value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} />
<input type="text" placeholder="Unlock steps JSON" value={newSpecialUnlockSteps} onChange={e => setNewSpecialUnlockSteps(e.target.value)} className="form-input" />
<button type="submit" className="btn-primary">Add Special</button>
</div>
</form>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{specials.map(special => (
<div key={special.id} style={{
background: '#f3f4f6',
padding: '0.25rem 0.75rem',
borderRadius: '999px',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.875rem'
}}>
<span>{special.name} ({special._count?.songs || 0})</span>
<button onClick={() => startEditSpecial(special)} className="btn-primary" style={{ marginRight: '0.5rem' }}>Edit</button>
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
</div>
))}
</div>
{editingSpecialId !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
<h3>Edit Special</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input type="text" value={editSpecialName} onChange={e => setEditSpecialName(e.target.value)} className="form-input" />
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} />
<input type="text" value={editSpecialUnlockSteps} onChange={e => setEditSpecialUnlockSteps(e.target.value)} className="form-input" />
<button onClick={saveEditedSpecial} className="btn-primary">Save</button>
<button onClick={() => setEditingSpecialId(null)} className="btn-secondary">Cancel</button>
</div>
</div>
)}
</div>
{/* Genre Management */} {/* Genre Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}> <div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2> <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
@@ -704,23 +857,33 @@ export default function AdminPage() {
style={{ flex: '1', minWidth: '200px' }} style={{ flex: '1', minWidth: '200px' }}
/> />
<select <select
value={selectedGenreFilter || ''} value={selectedGenreFilter}
onChange={e => setSelectedGenreFilter(e.target.value ? Number(e.target.value) : null)} onChange={e => setSelectedGenreFilter(e.target.value)}
className="form-input" className="form-input"
style={{ minWidth: '150px' }} style={{ minWidth: '150px' }}
> >
<option value="">All Genres</option> <option value="">All Content</option>
{genres.map(genre => ( <optgroup label="Genres">
<option key={genre.id} value={genre.id}> <option value="genre:-1">No Genre</option>
{genre.name} ({genre._count?.songs || 0}) {genres.map(genre => (
</option> <option key={genre.id} value={`genre:${genre.id}`}>
))} {genre.name} ({genre._count?.songs || 0})
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{special.name} ({special._count?.songs || 0})
</option>
))}
</optgroup>
</select> </select>
{(searchQuery || selectedGenreFilter) && ( {(searchQuery || selectedGenreFilter) && (
<button <button
onClick={() => { onClick={() => {
setSearchQuery(''); setSearchQuery('');
setSelectedGenreFilter(null); setSelectedGenreFilter('');
}} }}
style={{ style={{
padding: '0.5rem 1rem', padding: '0.5rem 1rem',
@@ -813,6 +976,24 @@ export default function AdminPage() {
</label> </label>
))} ))}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.25rem' }}>
{specials.map(special => (
<label key={special.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', color: '#4b5563' }}>
<input
type="checkbox"
checked={editSpecialIds.includes(special.id)}
onChange={e => {
if (e.target.checked) {
setEditSpecialIds([...editSpecialIds, special.id]);
} else {
setEditSpecialIds(editSpecialIds.filter(id => id !== special.id));
}
}}
/>
{special.name}
</label>
))}
</div>
</td> </td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}> <td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')} {new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -854,6 +1035,19 @@ export default function AdminPage() {
</span> </span>
))} ))}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.specials?.map(s => (
<span key={s.id} style={{
background: '#fce7f3',
color: '#9d174d',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem'
}}>
{s.name}
</span>
))}
</div>
</td> </td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}> <td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')} {new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -934,6 +1128,47 @@ export default function AdminPage() {
</div> </div>
)} )}
</div> </div>
<div className="admin-card" style={{ marginTop: '2rem', border: '1px solid #ef4444' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem', color: '#ef4444' }}>
Danger Zone
</h2>
<p style={{ marginBottom: '1rem', color: '#666' }}>
These actions are destructive and cannot be undone.
</p>
<button
onClick={async () => {
if (window.confirm('⚠️ WARNING: This will delete ALL data from the database (Songs, Genres, Specials, Puzzles) and re-import songs from the uploads folder.\n\nExisting genres and specials will be LOST and must be recreated manually.\n\nAre you sure you want to proceed?')) {
try {
setMessage('Rebuilding database... this may take a while.');
const res = await fetch('/api/admin/rebuild', { method: 'POST' });
if (res.ok) {
const data = await res.json();
alert(data.message + '\n\nPlease recreate your Genres and Specials now.');
window.location.reload();
} else {
alert('Rebuild failed. Check server logs.');
setMessage('Rebuild failed.');
}
} catch (e) {
console.error(e);
alert('Rebuild failed due to network error.');
}
}
}}
style={{
padding: '0.75rem 1.5rem',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
Rebuild Database
</button>
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { parseFile } from 'music-metadata';
import path from 'path';
import fs from 'fs/promises';
const prisma = new PrismaClient();
export async function POST() {
try {
console.log('[Rebuild] Starting database rebuild...');
// 1. Clear Database
// Delete in order to respect foreign keys
await prisma.dailyPuzzle.deleteMany();
// We need to clear the many-to-many relations first implicitly by deleting songs/genres/specials
// But explicit deletion of join tables isn't needed with Prisma's cascading deletes usually,
// but let's be safe and delete main entities.
await prisma.song.deleteMany();
await prisma.genre.deleteMany();
await prisma.special.deleteMany();
console.log('[Rebuild] Database cleared.');
// 2. Clear Covers Directory
const coversDir = path.join(process.cwd(), 'public/uploads/covers');
try {
const coverFiles = await fs.readdir(coversDir);
for (const file of coverFiles) {
if (file !== '.gitkeep') { // Preserve .gitkeep if it exists
await fs.unlink(path.join(coversDir, file));
}
}
console.log('[Rebuild] Covers directory cleared.');
} catch (e) {
console.log('[Rebuild] Covers directory might not exist or empty, creating it.');
await fs.mkdir(coversDir, { recursive: true });
}
// 3. Re-import Songs
const uploadsDir = path.join(process.cwd(), 'public/uploads');
const files = await fs.readdir(uploadsDir);
const mp3Files = files.filter(f => f.endsWith('.mp3'));
console.log(`[Rebuild] Found ${mp3Files.length} MP3 files to import.`);
let importedCount = 0;
for (const filename of mp3Files) {
const filePath = path.join(uploadsDir, filename);
try {
const metadata = await parseFile(filePath);
const title = metadata.common.title || 'Unknown Title';
const artist = metadata.common.artist || 'Unknown Artist';
let coverImage = null;
const picture = metadata.common.picture?.[0];
if (picture) {
const extension = picture.format.split('/')[1] || 'jpg';
const coverFilename = `cover-${Date.now()}-${Math.random().toString(36).substring(7)}.${extension}`;
const coverPath = path.join(coversDir, coverFilename);
await fs.writeFile(coverPath, picture.data);
coverImage = coverFilename;
}
await prisma.song.create({
data: {
title,
artist,
filename,
coverImage
}
});
importedCount++;
} catch (e) {
console.error(`[Rebuild] Failed to process ${filename}:`, e);
}
}
console.log(`[Rebuild] Successfully imported ${importedCount} songs.`);
return NextResponse.json({
success: true,
message: `Database rebuilt. Imported ${importedCount} songs.`
});
} catch (error) {
console.error('[Rebuild] Error:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -12,6 +12,7 @@ export async function GET() {
include: { include: {
puzzles: true, puzzles: true,
genres: true, genres: true,
specials: true,
}, },
}); });
@@ -25,6 +26,7 @@ export async function GET() {
coverImage: song.coverImage, coverImage: song.coverImage,
activations: song.puzzles.length, activations: song.puzzles.length,
genres: song.genres, genres: song.genres,
specials: song.specials,
})); }));
return NextResponse.json(songsWithActivations); return NextResponse.json(songsWithActivations);
@@ -146,7 +148,7 @@ export async function POST(request: Request) {
filename, filename,
coverImage, coverImage,
}, },
include: { genres: true } include: { genres: true, specials: true }
}); });
return NextResponse.json({ return NextResponse.json({
@@ -161,7 +163,7 @@ export async function POST(request: Request) {
export async function PUT(request: Request) { export async function PUT(request: Request) {
try { try {
const { id, title, artist, genreIds } = await request.json(); const { id, title, artist, genreIds, specialIds } = await request.json();
if (!id || !title || !artist) { if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
@@ -175,10 +177,16 @@ export async function PUT(request: Request) {
}; };
} }
if (specialIds) {
data.specials = {
set: specialIds.map((sId: number) => ({ id: sId }))
};
}
const updatedSong = await prisma.song.update({ const updatedSong = await prisma.song.update({
where: { id: Number(id) }, where: { id: Number(id) },
data, data,
include: { genres: true } include: { genres: true, specials: true }
}); });
return NextResponse.json(updatedSong); return NextResponse.json(updatedSong);

51
app/api/specials/route.ts Normal file
View File

@@ -0,0 +1,51 @@
import { PrismaClient, Special } from '@prisma/client';
import { NextResponse } from 'next/server';
const prisma = new PrismaClient();
export async function GET() {
const specials = await prisma.special.findMany({
orderBy: { name: 'asc' },
});
return NextResponse.json(specials);
}
export async function POST(request: Request) {
const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]' } = await request.json();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const special = await prisma.special.create({
data: {
name,
maxAttempts: Number(maxAttempts),
unlockSteps,
},
});
return NextResponse.json(special);
}
export async function DELETE(request: Request) {
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
await prisma.special.delete({ where: { id: Number(id) } });
return NextResponse.json({ success: true });
}
export async function PUT(request: Request) {
const { id, name, maxAttempts, unlockSteps } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
const updated = await prisma.special.update({
where: { id: Number(id) },
data: {
...(name && { name }),
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
...(unlockSteps && { unlockSteps }),
},
});
return NextResponse.json(updated);
}

View File

@@ -10,17 +10,40 @@ const prisma = new PrismaClient();
export default async function Home() { export default async function Home() {
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
return ( return (
<> <>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}> <div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link> <Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
{/* Genres */}
{genres.map(g => ( {genres.map(g => (
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}> <Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name} {g.name}
</Link> </Link>
))} ))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
))}
</div> </div>
</div> </div>
<Game dailyPuzzle={dailyPuzzle} genre={null} /> <Game dailyPuzzle={dailyPuzzle} genre={null} />

View File

@@ -0,0 +1,70 @@
import Game from '@/components/Game';
import { getOrCreateSpecialPuzzle } 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<{ name: string }>;
}
export default async function SpecialPage({ params }: PageProps) {
const { name } = await params;
const decodedName = decodeURIComponent(name);
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
{/* Genres */}
{genres.map(g => (
<Link
key={g.id}
href={`/${g.name}`}
style={{
color: '#4b5563',
textDecoration: 'none'
}}
>
{g.name}
</Link>
))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
style={{
fontWeight: s.name === decodedName ? 'bold' : 'normal',
textDecoration: s.name === decodedName ? 'underline' : 'none',
color: s.name === decodedName ? '#9d174d' : '#be185d'
}}
>
{s.name}
</Link>
))}
</div>
</div>
<Game
dailyPuzzle={dailyPuzzle}
genre={decodedName}
maxAttempts={dailyPuzzle?.maxAttempts}
unlockSteps={dailyPuzzle?.unlockSteps}
/>
</>
);
}

View File

@@ -17,12 +17,14 @@ interface GameProps {
coverImage: string | null; coverImage: string | null;
} | null; } | null;
genre?: string | null; genre?: string | null;
maxAttempts?: number;
unlockSteps?: number[];
} }
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60]; const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle, genre = null }: GameProps) { export default function Game({ dailyPuzzle, genre = null, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
const { gameState, statistics, addGuess } = useGameState(genre); const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false); const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false); const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('Share Result'); const [shareText, setShareText] = useState('Share Result');
@@ -54,13 +56,13 @@ export default function Game({ dailyPuzzle, genre = null }: GameProps) {
if (song.id === dailyPuzzle.songId) { if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true); addGuess(song.title, true);
setHasWon(true); setHasWon(true);
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id); sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre);
} else { } else {
addGuess(song.title, false); addGuess(song.title, false);
if (gameState.guesses.length + 1 >= 7) { if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true); setHasLost(true);
setHasWon(false); // Ensure won is false setHasWon(false); // Ensure won is false
sendGotifyNotification(7, 'lost', dailyPuzzle.id); sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
} }
} }
}; };
@@ -75,14 +77,14 @@ export default function Game({ dailyPuzzle, genre = null }: GameProps) {
addGuess("SKIPPED", false); addGuess("SKIPPED", false);
setHasLost(true); setHasLost(true);
setHasWon(false); setHasWon(false);
sendGotifyNotification(7, 'lost', dailyPuzzle.id); sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
}; };
const unlockedSeconds = UNLOCK_STEPS[Math.min(gameState.guesses.length, 6)]; const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
const handleShare = () => { const handleShare = () => {
let emojiGrid = ''; let emojiGrid = '';
const totalGuesses = 7; const totalGuesses = maxAttempts;
// Build the grid // Build the grid
for (let i = 0; i < totalGuesses; i++) { for (let i = 0; i < totalGuesses; i++) {
@@ -135,7 +137,7 @@ export default function Game({ dailyPuzzle, genre = null }: GameProps) {
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}> <div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar"> <div className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / 7</span> <span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span> <span>{unlockedSeconds}s unlocked</span>
</div> </div>
<AudioPlayer <AudioPlayer
@@ -167,7 +169,7 @@ export default function Game({ dailyPuzzle, genre = null }: GameProps) {
onClick={handleSkip} onClick={handleSkip}
className="skip-button" className="skip-button"
> >
Skip (+{UNLOCK_STEPS[Math.min(gameState.guesses.length + 1, 6)] - unlockedSeconds}s) Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
</button> </button>
) : ( ) : (
<button <button

View File

@@ -111,3 +111,92 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
return null; return null;
} }
} }
export async function getOrCreateSpecialPuzzle(specialName: string) {
try {
const today = getTodayISOString();
const special = await prisma.special.findUnique({
where: { name: specialName }
});
if (!special) return null;
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
where: {
date: today,
specialId: special.id
},
include: { song: true },
});
if (!dailyPuzzle) {
// Get songs available for this special
const allSongs = await prisma.song.findMany({
where: { specials: { some: { id: special.id } } },
include: {
puzzles: {
where: { specialId: special.id }
},
},
});
if (allSongs.length === 0) return null;
// Calculate weights
const weightedSongs = allSongs.map(song => ({
song,
weight: 1.0 / (song.puzzles.length + 1),
}));
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
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;
}
}
try {
dailyPuzzle = await prisma.dailyPuzzle.create({
data: {
date: today,
songId: selectedSong.id,
specialId: special.id
},
include: { song: true },
});
} catch (e) {
dailyPuzzle = await prisma.dailyPuzzle.findFirst({
where: {
date: today,
specialId: special.id
},
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,
special: specialName,
maxAttempts: special.maxAttempts,
unlockSteps: JSON.parse(special.unlockSteps)
};
} catch (error) {
console.error('Error in getOrCreateSpecialPuzzle:', error);
return null;
}
}

View File

@@ -25,7 +25,7 @@ export interface Statistics {
const STORAGE_KEY = 'hoerdle_game_state'; const STORAGE_KEY = 'hoerdle_game_state';
const STATS_KEY = 'hoerdle_statistics'; const STATS_KEY = 'hoerdle_statistics';
export function useGameState(genre: string | null = null) { export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
const [gameState, setGameState] = useState<GameState | null>(null); const [gameState, setGameState] = useState<GameState | null>(null);
const [statistics, setStatistics] = useState<Statistics | null>(null); const [statistics, setStatistics] = useState<Statistics | null>(null);
@@ -115,6 +115,10 @@ export function useGameState(genre: string | null = null) {
case 5: newStats.solvedIn5++; break; case 5: newStats.solvedIn5++; break;
case 6: newStats.solvedIn6++; break; case 6: newStats.solvedIn6++; break;
case 7: newStats.solvedIn7++; break; case 7: newStats.solvedIn7++; break;
default:
// For custom attempts > 7, we currently don't have specific stats buckets
// We could add a 'solvedInOther' or just ignore for now
break;
} }
} else { } else {
newStats.failed++; newStats.failed++;
@@ -129,7 +133,7 @@ export function useGameState(genre: string | null = null) {
const newGuesses = [...gameState.guesses, guess]; const newGuesses = [...gameState.guesses, guess];
const isSolved = correct; const isSolved = correct;
const isFailed = !correct && newGuesses.length >= 7; const isFailed = !correct && newGuesses.length >= maxAttempts;
const newState = { const newState = {
...gameState, ...gameState,

View File

@@ -0,0 +1,45 @@
-- CreateTable
CREATE TABLE "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
);
-- CreateTable
CREATE TABLE "Genre" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "DailyPuzzle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL,
"songId" INTEGER NOT NULL,
"genreId" INTEGER,
CONSTRAINT "DailyPuzzle_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "_GenreToSong" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_GenreToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Genre" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_GenreToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
-- CreateIndex
CREATE UNIQUE INDEX "DailyPuzzle_date_genreId_key" ON "DailyPuzzle"("date", "genreId");
-- CreateIndex
CREATE UNIQUE INDEX "_GenreToSong_AB_unique" ON "_GenreToSong"("A", "B");
-- CreateIndex
CREATE INDEX "_GenreToSong_B_index" ON "_GenreToSong"("B");

View File

@@ -0,0 +1,45 @@
-- CreateTable
CREATE TABLE "Special" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"maxAttempts" INTEGER NOT NULL DEFAULT 7,
"unlockSteps" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "_SongToSpecial" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_SongToSpecial_A_fkey" FOREIGN KEY ("A") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_SongToSpecial_B_fkey" FOREIGN KEY ("B") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_DailyPuzzle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL,
"songId" INTEGER NOT NULL,
"genreId" INTEGER,
"specialId" INTEGER,
CONSTRAINT "DailyPuzzle_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_DailyPuzzle" ("date", "genreId", "id", "songId") SELECT "date", "genreId", "id", "songId" FROM "DailyPuzzle";
DROP TABLE "DailyPuzzle";
ALTER TABLE "new_DailyPuzzle" RENAME TO "DailyPuzzle";
CREATE UNIQUE INDEX "DailyPuzzle_date_genreId_specialId_key" ON "DailyPuzzle"("date", "genreId", "specialId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "Special_name_key" ON "Special"("name");
-- CreateIndex
CREATE UNIQUE INDEX "_SongToSpecial_AB_unique" ON "_SongToSpecial"("A", "B");
-- CreateIndex
CREATE INDEX "_SongToSpecial_B_index" ON "_SongToSpecial"("B");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -19,6 +19,7 @@ model Song {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
puzzles DailyPuzzle[] puzzles DailyPuzzle[]
genres Genre[] genres Genre[]
specials Special[]
} }
model Genre { model Genre {
@@ -28,6 +29,16 @@ model Genre {
dailyPuzzles DailyPuzzle[] dailyPuzzles DailyPuzzle[]
} }
model Special {
id Int @id @default(autoincrement())
name String @unique
maxAttempts Int @default(7)
unlockSteps String // JSON array: "[2,4,7,11,16,30,60]"
createdAt DateTime @default(now())
songs Song[]
dailyPuzzles DailyPuzzle[]
}
model DailyPuzzle { model DailyPuzzle {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
date String // Format: YYYY-MM-DD date String // Format: YYYY-MM-DD
@@ -35,6 +46,8 @@ model DailyPuzzle {
song Song @relation(fields: [songId], references: [id]) song Song @relation(fields: [songId], references: [id])
genreId Int? genreId Int?
genre Genre? @relation(fields: [genreId], references: [id]) genre Genre? @relation(fields: [genreId], references: [id])
specialId Int?
special Special? @relation(fields: [specialId], references: [id])
@@unique([date, genreId]) // Unique puzzle per date per genre (null genreId = global puzzle) @@unique([date, genreId, specialId])
} }

72
scripts/restore_songs.ts Normal file
View File

@@ -0,0 +1,72 @@
import { PrismaClient } from '@prisma/client';
import { parseFile } from 'music-metadata';
import path from 'path';
import fs from 'fs/promises';
const prisma = new PrismaClient();
const UPLOADS_DIR = path.join(process.cwd(), 'public/uploads');
async function restoreSongs() {
console.log('Starting song restoration...');
try {
const files = await fs.readdir(UPLOADS_DIR);
const mp3Files = files.filter(f => f.endsWith('.mp3'));
console.log(`Found ${mp3Files.length} MP3 files.`);
for (const filename of mp3Files) {
// Check if song already exists
const existing = await prisma.song.findFirst({
where: { filename }
});
if (existing) {
console.log(`Skipping ${filename} (already exists)`);
continue;
}
const filePath = path.join(UPLOADS_DIR, filename);
try {
const metadata = await parseFile(filePath);
const title = metadata.common.title || 'Unknown Title';
const artist = metadata.common.artist || 'Unknown Artist';
// 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.
await prisma.song.create({
data: {
title,
artist,
filename,
// coverImage: null // We lose the cover link unfortunately, unless we re-extract
}
});
console.log(`Restored: ${title} - ${artist}`);
} catch (e) {
console.error(`Failed to process ${filename}:`, e);
}
}
console.log('Restoration complete.');
} catch (e) {
console.error('Error reading uploads directory:', e);
} finally {
await prisma.$disconnect();
}
}
restoreSongs();