Compare commits

...

3 Commits

Author SHA1 Message Date
Hördle Bot
c612da6371 Update Daily Puzzles table buttons to match Song Library style 2025-11-22 16:51:01 +01:00
Hördle Bot
fdd5463391 Add collapsible Daily Puzzles management table to admin area 2025-11-22 16:49:00 +01:00
Hördle Bot
ae9e4c504e Add Song of the Day filter and badges to Admin Song Library 2025-11-22 16:38:15 +01:00
3 changed files with 295 additions and 9 deletions

View File

@@ -21,6 +21,14 @@ interface Genre {
};
}
interface DailyPuzzle {
id: number;
date: string;
songId: number;
genreId: number | null;
specialId: number | null;
}
interface Song {
id: number;
title: string;
@@ -28,6 +36,7 @@ interface Song {
filename: string;
createdAt: string;
activations: number;
puzzles: DailyPuzzle[];
genres: Genre[];
specials: Special[];
}
@@ -88,6 +97,11 @@ export default function AdminPage() {
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
// Daily Puzzles state
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
// Check for existing auth on mount
useEffect(() => {
const authToken = localStorage.getItem('hoerdle_admin_auth');
@@ -95,6 +109,7 @@ export default function AdminPage() {
setIsAuthenticated(true);
fetchSongs();
fetchGenres();
fetchDailyPuzzles();
}
}, []);
@@ -108,6 +123,7 @@ export default function AdminPage() {
setIsAuthenticated(true);
fetchSongs();
fetchGenres();
fetchDailyPuzzles();
} else {
alert('Wrong password');
}
@@ -184,6 +200,63 @@ export default function AdminPage() {
else alert('Failed to delete special');
};
// Daily Puzzles functions
const fetchDailyPuzzles = async () => {
const res = await fetch('/api/admin/daily-puzzles');
if (res.ok) {
const data = await res.json();
setDailyPuzzles(data);
}
};
const handleDeletePuzzle = async (puzzleId: number) => {
if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return;
const res = await fetch('/api/admin/daily-puzzles', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ puzzleId }),
});
if (res.ok) {
fetchDailyPuzzles();
alert('Puzzle deleted and regenerated successfully');
} else {
alert('Failed to delete puzzle');
}
};
const handlePlayPuzzle = (puzzle: any) => {
if (playingPuzzleId === puzzle.id) {
// Pause
audioElement?.pause();
setPlayingPuzzleId(null);
setAudioElement(null);
} else {
// Stop any currently playing audio
if (audioElement) {
audioElement.pause();
setAudioElement(null);
}
const audio = new Audio(puzzle.song.audioUrl);
audio.play()
.then(() => {
setAudioElement(audio);
setPlayingPuzzleId(puzzle.id);
})
.catch((error) => {
console.error('Playback error:', error);
alert(`Failed to play audio: ${error.message}`);
setPlayingPuzzleId(null);
setAudioElement(null);
});
audio.onended = () => {
setPlayingPuzzleId(null);
setAudioElement(null);
};
}
};
const startEditSpecial = (special: Special) => {
setEditingSpecialId(special.id);
setEditSpecialName(special.name);
@@ -535,6 +608,9 @@ export default function AdminPage() {
} else if (selectedGenreFilter.startsWith('special:')) {
const specialId = Number(selectedGenreFilter.split(':')[1]);
matchesFilter = song.specials?.some(s => s.id === specialId) || false;
} else if (selectedGenreFilter === 'daily') {
const today = new Date().toISOString().split('T')[0];
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
}
}
@@ -827,13 +903,10 @@ export default function AdminPage() {
{message && (
<div style={{
marginTop: '1rem',
padding: '1rem',
background: message.includes('❌') ? '#fee2e2' : '#d4edda',
border: `1px solid ${message.includes('❌') ? '#ef4444' : '#28a745'}`,
borderRadius: '0.25rem',
whiteSpace: 'pre-line',
fontSize: '0.875rem',
fontFamily: 'monospace'
padding: '0.75rem',
background: '#d1fae5',
color: '#065f46',
borderRadius: '0.25rem'
}}>
{message}
</div>
@@ -841,6 +914,80 @@ export default function AdminPage() {
</form>
</div>
{/* Today's Daily Puzzles */}
<div className="admin-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
Today's Daily Puzzles
</h2>
<button
onClick={() => setShowDailyPuzzles(!showDailyPuzzles)}
style={{
padding: '0.5rem 1rem',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
{showDailyPuzzles ? ' Hide' : ' Show'}
</button>
</div>
{showDailyPuzzles && (dailyPuzzles.length === 0 ? (
<p style={{ color: '#6b7280' }}>No daily puzzles found for today.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e5e7eb' }}>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Category</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Song</th>
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: 'bold' }}>Artist</th>
<th style={{ padding: '0.75rem', textAlign: 'center', fontWeight: 'bold' }}>Actions</th>
</tr>
</thead>
<tbody>
{dailyPuzzles.map(puzzle => (
<tr key={puzzle.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '0.75rem' }}>
<span style={{
background: puzzle.categoryType === 'global' ? '#dbeafe' : puzzle.categoryType === 'special' ? '#fce7f3' : '#f3f4f6',
color: puzzle.categoryType === 'global' ? '#1e40af' : puzzle.categoryType === 'special' ? '#be185d' : '#374151',
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.875rem',
fontWeight: '500'
}}>
{puzzle.category}
</span>
</td>
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{puzzle.song.title}</td>
<td style={{ padding: '0.75rem' }}>{puzzle.song.artist}</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
<button
onClick={() => handlePlayPuzzle(puzzle)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title={playingPuzzleId === puzzle.id ? "Pause" : "Play"}
>
{playingPuzzleId === puzzle.id ? '' : ''}
</button>
<button
onClick={() => handleDeletePuzzle(puzzle.id)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title="Delete"
>
🗑️
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
))}
</div>
<div className="admin-card">
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
Song Library ({songs.length} songs)
@@ -863,6 +1010,7 @@ export default function AdminPage() {
style={{ minWidth: '150px' }}
>
<option value="">All Content</option>
<option value="daily">📅 Song of the Day</option>
<optgroup label="Genres">
<option value="genre:-1">No Genre</option>
{genres.map(genre => (
@@ -1020,8 +1168,40 @@ export default function AdminPage() {
</>
) : (
<>
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
<td style={{ padding: '0.75rem' }}>{song.artist}</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</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 => {
if (!p.genreId && !p.specialId) {
return (
<span key={p.id} style={{ background: '#dbeafe', color: '#1e40af', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #93c5fd' }}>
🌍 Global Daily
</span>
);
}
if (p.genreId) {
const genreName = genres.find(g => g.id === p.genreId)?.name;
return (
<span key={p.id} style={{ background: '#f3f4f6', color: '#374151', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #d1d5db' }}>
🏷️ {genreName} Daily
</span>
);
}
if (p.specialId) {
const specialName = specials.find(s => s.id === p.specialId)?.name;
return (
<span key={p.id} style={{ background: '#fce7f3', color: '#be185d', padding: '0.1rem 0.4rem', borderRadius: '0.25rem', fontSize: '0.7rem', border: '1px solid #fbcfe8' }}>
★ {specialName} Daily
</span>
);
}
return null;
})}
</div>
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres?.map(g => (

View File

@@ -0,0 +1,105 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET() {
try {
const today = new Date().toISOString().split('T')[0];
const dailyPuzzles = await prisma.dailyPuzzle.findMany({
where: {
date: today
},
include: {
song: true,
genre: true,
special: true
},
orderBy: [
{ genreId: 'asc' },
{ specialId: 'asc' }
]
});
const formattedPuzzles = dailyPuzzles.map(puzzle => ({
id: puzzle.id,
date: puzzle.date,
category: puzzle.specialId
? `${puzzle.special?.name}`
: puzzle.genreId
? `🏷️ ${puzzle.genre?.name}`
: '🌍 Global',
categoryType: puzzle.specialId ? 'special' : puzzle.genreId ? 'genre' : 'global',
genreId: puzzle.genreId,
specialId: puzzle.specialId,
song: {
id: puzzle.song.id,
title: puzzle.song.title,
artist: puzzle.song.artist,
filename: puzzle.song.filename,
audioUrl: `/uploads/${puzzle.song.filename}`
}
}));
return NextResponse.json(formattedPuzzles);
} catch (error) {
console.error('Error fetching daily puzzles:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const { puzzleId } = await request.json();
if (!puzzleId) {
return NextResponse.json({ error: 'Missing puzzleId' }, { status: 400 });
}
// Get puzzle details before deletion
const puzzle = await prisma.dailyPuzzle.findUnique({
where: { id: Number(puzzleId) }
});
if (!puzzle) {
return NextResponse.json({ error: 'Puzzle not found' }, { status: 404 });
}
// Delete the puzzle
await prisma.dailyPuzzle.delete({
where: { id: Number(puzzleId) }
});
// Regenerate puzzle based on type
const { getOrCreateDailyPuzzle, getOrCreateSpecialPuzzle } = await import('@/lib/dailyPuzzle');
let newPuzzle;
if (puzzle.specialId) {
const special = await prisma.special.findUnique({
where: { id: puzzle.specialId }
});
if (special) {
newPuzzle = await getOrCreateSpecialPuzzle(special.name);
}
} else if (puzzle.genreId) {
const genre = await prisma.genre.findUnique({
where: { id: puzzle.genreId }
});
if (genre) {
newPuzzle = await getOrCreateDailyPuzzle(genre.name);
}
} else {
newPuzzle = await getOrCreateDailyPuzzle(null);
}
return NextResponse.json({
success: true,
message: 'Puzzle deleted and regenerated',
newPuzzle
});
} catch (error) {
console.error('Error deleting puzzle:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -25,6 +25,7 @@ export async function GET() {
createdAt: song.createdAt,
coverImage: song.coverImage,
activations: song.puzzles.length,
puzzles: song.puzzles,
genres: song.genres,
specials: song.specials,
}));