Implement Specials feature, Admin UI enhancements, and Database Rebuild tool
This commit is contained in:
@@ -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} />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<optgroup label="Genres">
|
||||||
|
<option value="genre:-1">No Genre</option>
|
||||||
{genres.map(genre => (
|
{genres.map(genre => (
|
||||||
<option key={genre.id} value={genre.id}>
|
<option key={genre.id} value={`genre:${genre.id}`}>
|
||||||
{genre.name} ({genre._count?.songs || 0})
|
{genre.name} ({genre._count?.songs || 0})
|
||||||
</option>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
95
app/api/admin/rebuild/route.ts
Normal file
95
app/api/admin/rebuild/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
51
app/api/specials/route.ts
Normal 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);
|
||||||
|
}
|
||||||
25
app/page.tsx
25
app/page.tsx
@@ -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} />
|
||||||
|
|||||||
70
app/special/[name]/page.tsx
Normal file
70
app/special/[name]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
45
prisma/migrations/20251122121934_add_specials/migration.sql
Normal file
45
prisma/migrations/20251122121934_add_specials/migration.sql
Normal 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");
|
||||||
@@ -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");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
@@ -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
72
scripts/restore_songs.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user