Compare commits
3 Commits
e56d7893d7
...
15746f404a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15746f404a | ||
|
|
8c720e287f | ||
|
|
dc69fd1498 |
@@ -63,8 +63,7 @@ ENV PORT 3000
|
|||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
# Start command: migrate DB and start server
|
# Start command: migrate DB and start server
|
||||||
# Note: In production, migrations should ideally be run in a separate step or init container,
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts/docker-entrypoint.sh ./scripts/docker-entrypoint.sh
|
||||||
# but for simplicity with SQLite we can run push here or assume the volume has the DB.
|
RUN chmod +x ./scripts/docker-entrypoint.sh
|
||||||
# We'll use a custom start script or just run server, assuming user handles migration or we use prisma db push on start.
|
|
||||||
# Let's use a simple entrypoint script to ensure DB exists.
|
CMD ["./scripts/docker-entrypoint.sh"]
|
||||||
CMD ["node", "server.js"]
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
||||||
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
||||||
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
||||||
|
- **Genre-Management:**
|
||||||
|
- Erstellen und Verwalten von Musik-Genres.
|
||||||
|
- Manuelle Zuweisung von Genres zu Songs.
|
||||||
|
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
||||||
|
- Genre-spezifische tägliche Rätsel.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -68,6 +73,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- `TZ`: Zeitzone für täglichen Puzzle-Wechsel (Standard: `Europe/Berlin`)
|
- `TZ`: Zeitzone für täglichen Puzzle-Wechsel (Standard: `Europe/Berlin`)
|
||||||
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||||
|
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||||
|
|
||||||
2. **Starten:**
|
2. **Starten:**
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
43
app/[genre]/page.tsx
Normal file
43
app/[genre]/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Game from '@/components/Game';
|
||||||
|
import { getOrCreateDailyPuzzle } 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<{ genre: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GenrePage({ params }: PageProps) {
|
||||||
|
const { genre } = await params;
|
||||||
|
const decodedGenre = decodeURIComponent(genre);
|
||||||
|
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
||||||
|
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
||||||
|
{genres.map(g => (
|
||||||
|
<Link
|
||||||
|
key={g.id}
|
||||||
|
href={`/${g.name}`}
|
||||||
|
style={{
|
||||||
|
fontWeight: g.name === decodedGenre ? 'bold' : 'normal',
|
||||||
|
textDecoration: g.name === decodedGenre ? 'underline' : 'none',
|
||||||
|
color: g.name === decodedGenre ? 'black' : '#4b5563'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Genre {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
_count?: {
|
||||||
|
songs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -9,6 +17,7 @@ interface Song {
|
|||||||
filename: string;
|
filename: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
activations: number;
|
activations: number;
|
||||||
|
genres: Genre[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
|
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
|
||||||
@@ -20,11 +29,22 @@ export default function AdminPage() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [songs, setSongs] = useState<Song[]>([]);
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
|
const [genres, setGenres] = useState<Genre[]>([]);
|
||||||
|
const [newGenreName, setNewGenreName] = useState('');
|
||||||
|
|
||||||
// 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[]>([]);
|
||||||
|
|
||||||
|
// Post-upload state
|
||||||
|
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
||||||
|
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// AI Categorization state
|
||||||
|
const [isCategorizing, setIsCategorizing] = useState(false);
|
||||||
|
const [categorizationResults, setCategorizationResults] = useState<any>(null);
|
||||||
|
|
||||||
// Sort state
|
// Sort state
|
||||||
const [sortField, setSortField] = useState<SortField>('artist');
|
const [sortField, setSortField] = useState<SortField>('artist');
|
||||||
@@ -45,6 +65,7 @@ export default function AdminPage() {
|
|||||||
if (authToken === 'authenticated') {
|
if (authToken === 'authenticated') {
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
fetchSongs();
|
fetchSongs();
|
||||||
|
fetchGenres();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -57,6 +78,7 @@ export default function AdminPage() {
|
|||||||
localStorage.setItem('hoerdle_admin_auth', 'authenticated');
|
localStorage.setItem('hoerdle_admin_auth', 'authenticated');
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
fetchSongs();
|
fetchSongs();
|
||||||
|
fetchGenres();
|
||||||
} else {
|
} else {
|
||||||
alert('Wrong password');
|
alert('Wrong password');
|
||||||
}
|
}
|
||||||
@@ -70,6 +92,69 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchGenres = async () => {
|
||||||
|
const res = await fetch('/api/genres');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setGenres(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createGenre = async () => {
|
||||||
|
if (!newGenreName.trim()) return;
|
||||||
|
const res = await fetch('/api/genres', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: newGenreName }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setNewGenreName('');
|
||||||
|
fetchGenres();
|
||||||
|
} else {
|
||||||
|
alert('Failed to create genre');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGenre = async (id: number) => {
|
||||||
|
if (!confirm('Delete this genre?')) return;
|
||||||
|
const res = await fetch('/api/genres', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchGenres();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete genre');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAICategorization = async () => {
|
||||||
|
if (!confirm('This will categorize all songs without genres using AI. Continue?')) return;
|
||||||
|
|
||||||
|
setIsCategorizing(true);
|
||||||
|
setCategorizationResults(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/categorize', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setCategorizationResults(data);
|
||||||
|
fetchSongs(); // Refresh song list
|
||||||
|
fetchGenres(); // Refresh genre counts
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
alert(`Categorization failed: ${error.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to categorize songs');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsCategorizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpload = async (e: React.FormEvent) => {
|
const handleUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -104,29 +189,62 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
setMessage(statusMessage);
|
setMessage(statusMessage);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
setUploadedSong(data.song);
|
||||||
|
setUploadGenreIds([]); // Reset selection
|
||||||
fetchSongs();
|
fetchSongs();
|
||||||
} else {
|
} else {
|
||||||
setMessage('Upload failed.');
|
setMessage('Upload failed.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveUploadedSongGenres = async () => {
|
||||||
|
if (!uploadedSong) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/songs', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: uploadedSong.id,
|
||||||
|
title: uploadedSong.title,
|
||||||
|
artist: uploadedSong.artist,
|
||||||
|
genreIds: uploadGenreIds
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setUploadedSong(null);
|
||||||
|
setUploadGenreIds([]);
|
||||||
|
fetchSongs();
|
||||||
|
setMessage(prev => prev + '\n✅ Genres assigned successfully!');
|
||||||
|
} else {
|
||||||
|
alert('Failed to assign genres');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startEditing = (song: Song) => {
|
const startEditing = (song: Song) => {
|
||||||
setEditingId(song.id);
|
setEditingId(song.id);
|
||||||
setEditTitle(song.title);
|
setEditTitle(song.title);
|
||||||
setEditArtist(song.artist);
|
setEditArtist(song.artist);
|
||||||
|
setEditGenreIds(song.genres.map(g => g.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setEditTitle('');
|
setEditTitle('');
|
||||||
setEditArtist('');
|
setEditArtist('');
|
||||||
|
setEditGenreIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEditing = async (id: number) => {
|
const saveEditing = async (id: number) => {
|
||||||
const res = await fetch('/api/songs', {
|
const res = await fetch('/api/songs', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, title: editTitle, artist: editArtist }),
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
title: editTitle,
|
||||||
|
artist: editArtist,
|
||||||
|
genreIds: editGenreIds
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -255,6 +373,124 @@ export default function AdminPage() {
|
|||||||
<div className="admin-container">
|
<div className="admin-container">
|
||||||
<h1 className="title" style={{ marginBottom: '2rem' }}>Admin Dashboard</h1>
|
<h1 className="title" style={{ marginBottom: '2rem' }}>Admin Dashboard</h1>
|
||||||
|
|
||||||
|
{/* Genre Management */}
|
||||||
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newGenreName}
|
||||||
|
onChange={e => setNewGenreName(e.target.value)}
|
||||||
|
placeholder="New Genre Name"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: '200px' }}
|
||||||
|
/>
|
||||||
|
<button onClick={createGenre} className="btn-primary">Add Genre</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
{genres.map(genre => (
|
||||||
|
<div key={genre.id} style={{
|
||||||
|
background: '#f3f4f6',
|
||||||
|
padding: '0.25rem 0.75rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
<span>{genre.name} ({genre._count?.songs || 0})</span>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteGenre(genre.id)}
|
||||||
|
style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#ef4444', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Categorization */}
|
||||||
|
<div style={{ marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid #e5e7eb' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleAICategorization}
|
||||||
|
disabled={isCategorizing || genres.length === 0}
|
||||||
|
className="btn-primary"
|
||||||
|
style={{
|
||||||
|
opacity: isCategorizing || genres.length === 0 ? 0.5 : 1,
|
||||||
|
cursor: isCategorizing || genres.length === 0 ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCategorizing ? '🤖 Categorizing...' : '🤖 Auto-Categorize Songs with AI'}
|
||||||
|
</button>
|
||||||
|
{genres.length === 0 && (
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||||||
|
Please create at least one genre first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categorization Results */}
|
||||||
|
{categorizationResults && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: '#f0fdf4',
|
||||||
|
border: '1px solid #86efac',
|
||||||
|
borderRadius: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem', color: '#166534' }}>
|
||||||
|
✅ Categorization Complete
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||||
|
{categorizationResults.message}
|
||||||
|
</p>
|
||||||
|
{categorizationResults.results && categorizationResults.results.length > 0 && (
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<p style={{ fontWeight: 'bold', fontSize: '0.875rem', marginBottom: '0.5rem' }}>Updated Songs:</p>
|
||||||
|
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||||
|
{categorizationResults.results.map((result: any) => (
|
||||||
|
<div key={result.songId} style={{
|
||||||
|
padding: '0.5rem',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
<strong>{result.title}</strong> by {result.artist}
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem', flexWrap: 'wrap' }}>
|
||||||
|
{result.assignedGenres.map((genre: string) => (
|
||||||
|
<span key={genre} style={{
|
||||||
|
background: '#dbeafe',
|
||||||
|
padding: '0.1rem 0.4rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}>
|
||||||
|
{genre}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setCategorizationResults(null)}
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload New Song</h2>
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload New Song</h2>
|
||||||
<form onSubmit={handleUpload}>
|
<form onSubmit={handleUpload}>
|
||||||
@@ -286,6 +522,41 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Post-upload Genre Selection */}
|
||||||
|
{uploadedSong && (
|
||||||
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem', border: '1px solid #c7d2fe' }}>
|
||||||
|
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Assign Genres to "{uploadedSong.title}"</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||||
|
{genres.map(genre => (
|
||||||
|
<label key={genre.id} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
background: 'white',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: uploadGenreIds.includes(genre.id) ? '1px solid #4f46e5' : '1px solid #e5e7eb'
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={uploadGenreIds.includes(genre.id)}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setUploadGenreIds([...uploadGenreIds, genre.id]);
|
||||||
|
} else {
|
||||||
|
setUploadGenreIds(uploadGenreIds.filter(id => id !== genre.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{genre.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={saveUploadedSongGenres} className="btn-primary">Save Genres</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
@@ -326,6 +597,7 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
|
<th style={{ padding: '0.75rem' }}>Genres</th>
|
||||||
<th
|
<th
|
||||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||||
onClick={() => handleSort('createdAt')}
|
onClick={() => handleSort('createdAt')}
|
||||||
@@ -361,6 +633,26 @@ export default function AdminPage() {
|
|||||||
style={{ padding: '0.25rem' }}
|
style={{ padding: '0.25rem' }}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{genres.map(genre => (
|
||||||
|
<label key={genre.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editGenreIds.includes(genre.id)}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setEditGenreIds([...editGenreIds, genre.id]);
|
||||||
|
} else {
|
||||||
|
setEditGenreIds(editGenreIds.filter(id => id !== genre.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{genre.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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')}
|
||||||
</td>
|
</td>
|
||||||
@@ -388,6 +680,20 @@ export default function AdminPage() {
|
|||||||
<>
|
<>
|
||||||
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
|
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
|
||||||
<td style={{ padding: '0.75rem' }}>{song.artist}</td>
|
<td style={{ padding: '0.75rem' }}>{song.artist}</td>
|
||||||
|
<td style={{ padding: '0.75rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{song.genres?.map(g => (
|
||||||
|
<span key={g.id} style={{
|
||||||
|
background: '#e5e7eb',
|
||||||
|
padding: '0.1rem 0.4rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
fontSize: '0.7rem'
|
||||||
|
}}>
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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')}
|
||||||
</td>
|
</td>
|
||||||
@@ -423,7 +729,7 @@ export default function AdminPage() {
|
|||||||
))}
|
))}
|
||||||
{paginatedSongs.length === 0 && (
|
{paginatedSongs.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
<td colSpan={7} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
||||||
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
|
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
164
app/api/categorize/route.ts
Normal file
164
app/api/categorize/route.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||||
|
const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku';
|
||||||
|
|
||||||
|
interface CategorizeResult {
|
||||||
|
songId: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
assignedGenres: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
if (!OPENROUTER_API_KEY) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'OPENROUTER_API_KEY not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all songs without genres
|
||||||
|
const uncategorizedSongs = await prisma.song.findMany({
|
||||||
|
where: {
|
||||||
|
genres: {
|
||||||
|
none: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
genres: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uncategorizedSongs.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
message: 'No uncategorized songs found',
|
||||||
|
results: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all available genres
|
||||||
|
const allGenres = await prisma.genre.findMany({
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allGenres.length === 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'No genres available. Please create genres first.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: CategorizeResult[] = [];
|
||||||
|
|
||||||
|
// Process each song
|
||||||
|
for (const song of uncategorizedSongs) {
|
||||||
|
try {
|
||||||
|
const genreNames = allGenres.map(g => g.name);
|
||||||
|
|
||||||
|
const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres.
|
||||||
|
|
||||||
|
Song: "${song.title}" by ${song.artist}
|
||||||
|
Available genres: ${genreNames.join(', ')}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Select 0-3 genres that best match this song
|
||||||
|
- Only use genres from the available list
|
||||||
|
- Respond with ONLY a JSON array of genre names, nothing else
|
||||||
|
- If no genres match well, return an empty array []
|
||||||
|
|
||||||
|
Example response: ["Rock", "Alternative"]
|
||||||
|
|
||||||
|
Your response:`;
|
||||||
|
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://hoerdle.elpatron.me',
|
||||||
|
'X-Title': 'Hördle Genre Categorization'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OPENROUTER_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 100
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`OpenRouter API error for song ${song.id}:`, await response.text());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const aiResponse = data.choices?.[0]?.message?.content?.trim() || '[]';
|
||||||
|
|
||||||
|
// Parse AI response
|
||||||
|
let suggestedGenreNames: string[] = [];
|
||||||
|
try {
|
||||||
|
suggestedGenreNames = JSON.parse(aiResponse);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to parse AI response for song ${song.id}:`, aiResponse);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only valid genres and get their IDs
|
||||||
|
const genreIds = allGenres
|
||||||
|
.filter(g => suggestedGenreNames.includes(g.name))
|
||||||
|
.map(g => g.id)
|
||||||
|
.slice(0, 3); // Max 3 genres
|
||||||
|
|
||||||
|
if (genreIds.length > 0) {
|
||||||
|
// Update song with genres
|
||||||
|
await prisma.song.update({
|
||||||
|
where: { id: song.id },
|
||||||
|
data: {
|
||||||
|
genres: {
|
||||||
|
connect: genreIds.map(id => ({ id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
songId: song.id,
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
assignedGenres: suggestedGenreNames.filter(name =>
|
||||||
|
allGenres.some(g => g.name === name)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing song ${song.id}:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
message: `Processed ${uncategorizedSongs.length} songs, categorized ${results.length}`,
|
||||||
|
totalProcessed: uncategorizedSongs.length,
|
||||||
|
totalCategorized: results.length,
|
||||||
|
results
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Categorization error:', error);
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Failed to categorize songs' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,78 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
import { getTodayISOString } from '@/lib/dateUtils';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
export async function GET(request: Request) {
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
try {
|
||||||
const today = getTodayISOString();
|
const { searchParams } = new URL(request.url);
|
||||||
|
const genreName = searchParams.get('genre');
|
||||||
|
|
||||||
let dailyPuzzle = await prisma.dailyPuzzle.findUnique({
|
const puzzle = await getOrCreateDailyPuzzle(genreName);
|
||||||
where: { date: today },
|
|
||||||
include: { song: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[Daily Puzzle] Date: ${today}, Found existing: ${!!dailyPuzzle}`);
|
if (!puzzle) {
|
||||||
|
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
||||||
if (!dailyPuzzle) {
|
|
||||||
// Get all songs with their usage count
|
|
||||||
const allSongs = await prisma.song.findMany({
|
|
||||||
include: {
|
|
||||||
puzzles: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (allSongs.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'No songs available' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate weights: songs never used get weight 1.0,
|
|
||||||
// songs used once get 0.5, twice 0.33, etc.
|
|
||||||
const weightedSongs = allSongs.map(song => ({
|
|
||||||
song,
|
|
||||||
weight: 1.0 / (song.puzzles.length + 1),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Calculate total weight
|
|
||||||
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
|
||||||
|
|
||||||
// Pick a random song based on weights
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the daily puzzle
|
|
||||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
|
||||||
data: {
|
|
||||||
date: today,
|
|
||||||
songId: selectedSong.id,
|
|
||||||
},
|
|
||||||
include: { song: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[Daily Puzzle] Created new puzzle for ${today} with song: ${selectedSong.title} (ID: ${selectedSong.id})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dailyPuzzle) {
|
return NextResponse.json(puzzle);
|
||||||
return NextResponse.json({ error: 'Failed to create puzzle' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching daily puzzle:', error);
|
console.error('Error fetching daily puzzle:', error);
|
||||||
|
|||||||
59
app/api/genres/route.ts
Normal file
59
app/api/genres/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const genres = await prisma.genre.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { songs: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json(genres);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching genres:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { name } = await request.json();
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const genre = await prisma.genre.create({
|
||||||
|
data: { name: name.trim() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(genre);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating genre:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.genre.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting genre:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export async function GET() {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
puzzles: true,
|
puzzles: true,
|
||||||
|
genres: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export async function GET() {
|
|||||||
createdAt: song.createdAt,
|
createdAt: song.createdAt,
|
||||||
coverImage: song.coverImage,
|
coverImage: song.coverImage,
|
||||||
activations: song.puzzles.length,
|
activations: song.puzzles.length,
|
||||||
|
genres: song.genres,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(songsWithActivations);
|
return NextResponse.json(songsWithActivations);
|
||||||
@@ -144,6 +146,7 @@ export async function POST(request: Request) {
|
|||||||
filename,
|
filename,
|
||||||
coverImage,
|
coverImage,
|
||||||
},
|
},
|
||||||
|
include: { genres: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -158,15 +161,24 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { id, title, artist } = await request.json();
|
const { id, title, artist, genreIds } = 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data: any = { title, artist };
|
||||||
|
|
||||||
|
if (genreIds) {
|
||||||
|
data.genres = {
|
||||||
|
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const updatedSong = await prisma.song.update({
|
const updatedSong = await prisma.song.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: { title, artist },
|
data,
|
||||||
|
include: { genres: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(updatedSong);
|
return NextResponse.json(updatedSong);
|
||||||
|
|||||||
90
app/page.tsx
90
app/page.tsx
@@ -1,81 +1,29 @@
|
|||||||
import Game from '@/components/Game';
|
import Game from '@/components/Game';
|
||||||
import { getTodayISOString } from '@/lib/dateUtils';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// PrismaClient is attached to the `global` object in development to prevent
|
|
||||||
// exhausting your database connection limit.
|
|
||||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
|
||||||
|
|
||||||
const prisma = globalForPrisma.prisma || new PrismaClient();
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
|
||||||
|
|
||||||
async function getDailyPuzzle() {
|
|
||||||
try {
|
|
||||||
const today = getTodayISOString();
|
|
||||||
console.log(`[getDailyPuzzle] Checking puzzle for date: ${today}`);
|
|
||||||
|
|
||||||
let dailyPuzzle = await prisma.dailyPuzzle.findUnique({
|
|
||||||
where: { date: today },
|
|
||||||
include: { song: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dailyPuzzle) {
|
|
||||||
console.log('[getDailyPuzzle] No puzzle found, attempting to create...');
|
|
||||||
const songsCount = await prisma.song.count();
|
|
||||||
console.log(`[getDailyPuzzle] Found ${songsCount} songs in DB`);
|
|
||||||
|
|
||||||
if (songsCount > 0) {
|
|
||||||
const skip = Math.floor(Math.random() * songsCount);
|
|
||||||
const randomSong = await prisma.song.findFirst({ skip });
|
|
||||||
|
|
||||||
if (randomSong) {
|
|
||||||
try {
|
|
||||||
dailyPuzzle = await prisma.dailyPuzzle.create({
|
|
||||||
data: { date: today, songId: randomSong.id },
|
|
||||||
include: { song: true },
|
|
||||||
});
|
|
||||||
console.log(`[getDailyPuzzle] Created puzzle for song: ${randomSong.title}`);
|
|
||||||
} catch (createError) {
|
|
||||||
// Handle race condition: if another request created it in the meantime
|
|
||||||
console.log('[getDailyPuzzle] Creation failed, trying to fetch again (likely race condition)');
|
|
||||||
dailyPuzzle = await prisma.dailyPuzzle.findUnique({
|
|
||||||
where: { date: today },
|
|
||||||
include: { song: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[getDailyPuzzle] No songs available to create puzzle');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dailyPuzzle) {
|
|
||||||
console.log('[getDailyPuzzle] Failed to get or create puzzle');
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[getDailyPuzzle] Error:', e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const dailyPuzzle = await getDailyPuzzle();
|
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||||
|
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Game dailyPuzzle={dailyPuzzle} />
|
<>
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
||||||
|
{genres.map(g => (
|
||||||
|
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||||
|
{g.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ interface GameProps {
|
|||||||
artist: string;
|
artist: string;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
genre?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||||
|
|
||||||
export default function Game({ dailyPuzzle }: GameProps) {
|
export default function Game({ dailyPuzzle, genre = null }: GameProps) {
|
||||||
const { gameState, statistics, addGuess } = useGameState();
|
const { gameState, statistics, addGuess } = useGameState(genre);
|
||||||
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');
|
||||||
@@ -42,7 +43,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
|||||||
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
<h2>No Puzzle Available</h2>
|
<h2>No Puzzle Available</h2>
|
||||||
<p>Could not generate a daily puzzle.</p>
|
<p>Could not generate a daily puzzle.</p>
|
||||||
<p>Please ensure there are songs in the database.</p>
|
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
|
||||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -58,6 +59,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
|||||||
addGuess(song.title, false);
|
addGuess(song.title, false);
|
||||||
if (gameState.guesses.length + 1 >= 7) {
|
if (gameState.guesses.length + 1 >= 7) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
|
setHasWon(false); // Ensure won is false
|
||||||
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
|
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
|||||||
setLastAction('SKIP');
|
setLastAction('SKIP');
|
||||||
addGuess("SKIPPED", false);
|
addGuess("SKIPPED", false);
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
|
setHasWon(false);
|
||||||
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
|
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,7 +101,8 @@ export default function Game({ dailyPuzzle }: GameProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const speaker = hasWon ? '🔉' : '🔇';
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
const text = `Hördle #${dailyPuzzle.id}\n\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`;
|
const genreText = genre ? `Genre: ${genre}\n` : '';
|
||||||
|
const text = `Hördle #${dailyPuzzle.id}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`;
|
||||||
|
|
||||||
// Fallback method for copying to clipboard
|
// Fallback method for copying to clipboard
|
||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
- TZ=Europe/Berlin # Timezone for daily puzzle rotation
|
- TZ=Europe/Berlin # Timezone for daily puzzle rotation
|
||||||
- GOTIFY_URL=https://gotify.example.com
|
- GOTIFY_URL=https://gotify.example.com
|
||||||
- GOTIFY_APP_TOKEN=your_gotify_token
|
- GOTIFY_APP_TOKEN=your_gotify_token
|
||||||
|
- OPENROUTER_API_KEY=your_openrouter_api_key
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./public/uploads:/app/public/uploads
|
- ./public/uploads:/app/public/uploads
|
||||||
|
|||||||
113
lib/dailyPuzzle.ts
Normal file
113
lib/dailyPuzzle.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getTodayISOString } from './dateUtils';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||||
|
try {
|
||||||
|
const today = getTodayISOString();
|
||||||
|
let genreId: number | null = null;
|
||||||
|
|
||||||
|
if (genreName) {
|
||||||
|
const genre = await prisma.genre.findUnique({
|
||||||
|
where: { name: genreName }
|
||||||
|
});
|
||||||
|
if (genre) {
|
||||||
|
genreId = genre.id;
|
||||||
|
} else {
|
||||||
|
return null; // Genre not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||||
|
where: {
|
||||||
|
date: today,
|
||||||
|
genreId: genreId
|
||||||
|
},
|
||||||
|
include: { song: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Daily Puzzle] Date: ${today}, Genre: ${genreName || 'Global'}, Found existing: ${!!dailyPuzzle}`);
|
||||||
|
|
||||||
|
if (!dailyPuzzle) {
|
||||||
|
// Get songs available for this genre
|
||||||
|
const whereClause = genreId
|
||||||
|
? { genres: { some: { id: genreId } } }
|
||||||
|
: {}; // Global puzzle picks from ALL songs
|
||||||
|
|
||||||
|
const allSongs = await prisma.song.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
puzzles: {
|
||||||
|
where: { genreId: genreId }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allSongs.length === 0) {
|
||||||
|
console.log(`[Daily Puzzle] No songs available for genre: ${genreName || 'Global'}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate weights
|
||||||
|
const weightedSongs = allSongs.map(song => ({
|
||||||
|
song,
|
||||||
|
weight: 1.0 / (song.puzzles.length + 1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate total weight
|
||||||
|
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
|
||||||
|
|
||||||
|
// Pick a random song based on weights
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the daily puzzle
|
||||||
|
try {
|
||||||
|
dailyPuzzle = await prisma.dailyPuzzle.create({
|
||||||
|
data: {
|
||||||
|
date: today,
|
||||||
|
songId: selectedSong.id,
|
||||||
|
genreId: genreId
|
||||||
|
},
|
||||||
|
include: { song: true },
|
||||||
|
});
|
||||||
|
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genreName || 'Global'}) with song: ${selectedSong.title}`);
|
||||||
|
} catch (e) {
|
||||||
|
// Handle race condition
|
||||||
|
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)');
|
||||||
|
dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||||
|
where: {
|
||||||
|
date: today,
|
||||||
|
genreId: genreId
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
genre: genreName
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getOrCreateDailyPuzzle:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,13 +25,20 @@ 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() {
|
export function useGameState(genre: string | null = null) {
|
||||||
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);
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
||||||
|
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
||||||
|
|
||||||
|
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
||||||
|
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load game state
|
// Load game state
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const storageKey = getStorageKey();
|
||||||
|
const stored = localStorage.getItem(storageKey);
|
||||||
const today = getTodayISOString();
|
const today = getTodayISOString();
|
||||||
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
@@ -48,7 +55,7 @@ export function useGameState() {
|
|||||||
lastPlayed: Date.now(),
|
lastPlayed: Date.now(),
|
||||||
};
|
};
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No state
|
// No state
|
||||||
@@ -60,11 +67,12 @@ export function useGameState() {
|
|||||||
lastPlayed: Date.now(),
|
lastPlayed: Date.now(),
|
||||||
};
|
};
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load statistics
|
// Load statistics
|
||||||
const storedStats = localStorage.getItem(STATS_KEY);
|
const statsKey = getStatsKey();
|
||||||
|
const storedStats = localStorage.getItem(statsKey);
|
||||||
if (storedStats) {
|
if (storedStats) {
|
||||||
const parsedStats = JSON.parse(storedStats);
|
const parsedStats = JSON.parse(storedStats);
|
||||||
// Migration for existing stats without solvedIn7
|
// Migration for existing stats without solvedIn7
|
||||||
@@ -84,13 +92,13 @@ export function useGameState() {
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
setStatistics(newStats);
|
setStatistics(newStats);
|
||||||
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
|
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [genre]); // Re-run when genre changes
|
||||||
|
|
||||||
const saveState = (newState: GameState) => {
|
const saveState = (newState: GameState) => {
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
const updateStatistics = (attempts: number, solved: boolean) => {
|
||||||
@@ -113,7 +121,7 @@ export function useGameState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStatistics(newStats);
|
setStatistics(newStats);
|
||||||
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
|
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addGuess = (guess: string, correct: boolean) => {
|
const addGuess = (guess: string, correct: boolean) => {
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "prisma migrate deploy && next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"music-metadata": "^11.10.2",
|
"music-metadata": "^11.10.2",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0"
|
||||||
},
|
},
|
||||||
@@ -22,7 +23,6 @@
|
|||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"prisma": "^6.19.0",
|
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,11 +18,23 @@ model Song {
|
|||||||
coverImage String? // Filename in public/uploads/covers
|
coverImage String? // Filename in public/uploads/covers
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
|
genres Genre[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Genre {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
songs Song[]
|
||||||
|
dailyPuzzles DailyPuzzle[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model DailyPuzzle {
|
model DailyPuzzle {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
date String @unique // Format: YYYY-MM-DD
|
date String // Format: YYYY-MM-DD
|
||||||
songId Int
|
songId Int
|
||||||
song Song @relation(fields: [songId], references: [id])
|
song Song @relation(fields: [songId], references: [id])
|
||||||
|
genreId Int?
|
||||||
|
genre Genre? @relation(fields: [genreId], references: [id])
|
||||||
|
|
||||||
|
@@unique([date, genreId]) // Unique puzzle per date per genre (null genreId = global puzzle)
|
||||||
}
|
}
|
||||||
|
|||||||
12
scripts/docker-entrypoint.sh
Executable file
12
scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting deployment..."
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "Running database migrations..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
echo "Starting application..."
|
||||||
|
exec node server.js
|
||||||
@@ -1,41 +1,55 @@
|
|||||||
# Hördle - Walkthrough
|
# Genre/Tag System Implementation Walkthrough
|
||||||
|
|
||||||
Die Hördle Webapp ist nun einsatzbereit. Hier ist eine Anleitung, wie du sie nutzt und verwaltest.
|
## Overview
|
||||||
|
Implemented a comprehensive Genre/Tag system for Hördle, allowing songs to be categorized and users to play genre-specific daily puzzles.
|
||||||
|
|
||||||
## Starten der App
|
## Changes
|
||||||
|
|
||||||
1. Öffne ein Terminal im Projektverzeichnis: `/home/markus/hördle`
|
### Database
|
||||||
2. Starte den Entwicklungsserver:
|
- **New Model**: `Genre` (id, name, songs, dailyPuzzles).
|
||||||
```bash
|
- **Updated Model**: `Song` (added M-N relation to `Genre`).
|
||||||
npm run dev
|
- **Updated Model**: `DailyPuzzle` (added optional `genreId`, updated unique constraint to `[date, genreId]`).
|
||||||
```
|
|
||||||
3. Öffne `http://localhost:3000` im Browser.
|
|
||||||
|
|
||||||
## Admin-Bereich (Songs hochladen)
|
### Backend API
|
||||||
|
- **`app/api/genres/route.ts`**: New endpoints for GET (list) and POST (create) and DELETE genres.
|
||||||
|
- **`app/api/songs/route.ts`**: Updated to handle genre assignment (POST/PUT) and retrieval (GET).
|
||||||
|
- **`app/api/daily/route.ts`**: Updated to support `?genre=<name>` query parameter.
|
||||||
|
- **`lib/dailyPuzzle.ts`**: Shared logic for fetching/creating daily puzzles (Global or Genre-specific).
|
||||||
|
|
||||||
1. Gehe zu `http://localhost:3000/admin`
|
### Frontend (Admin)
|
||||||
2. Logge dich ein. Das Standard-Passwort ist `admin123` (kann in `.env` geändert werden).
|
- **Genre Management**: Create and delete genres.
|
||||||
3. **Upload**: Wähle eine MP3-Datei aus. Titel und Interpret werden automatisch aus den ID3-Tags ausgelesen, falls du die Felder leer lässt.
|
- **Song Assignment**: Assign genres during upload and edit.
|
||||||
4. **Bibliothek**: Unter dem Upload-Formular siehst du eine Tabelle aller verfügbaren Songs.
|
- **Post-Upload Workflow**: Prompt to assign genres immediately after upload.
|
||||||
|
- **Song List**: Display assigned genres in the table.
|
||||||
|
|
||||||
## Spielablauf
|
### Frontend (User)
|
||||||
|
- **Genre Selection**: Links on the main page to switch between Global and Genre-specific games.
|
||||||
|
- **Game Logic**: Refactored to support independent game states per genre (localStorage keys: `hoerdle_game_state_<genre>`).
|
||||||
|
- **Dynamic Route**: `app/[genre]/page.tsx` for genre-specific URLs.
|
||||||
|
- **Sharing**: Share text now includes the genre name.
|
||||||
|
|
||||||
- Das Spiel wählt jeden Tag (um Mitternacht) automatisch einen neuen Song aus der Datenbank.
|
### Deployment
|
||||||
- Wenn noch kein Song für den heutigen Tag festgelegt wurde, wird beim ersten Aufruf der Seite zufällig einer ausgewählt.
|
- **Auto-Migration**: Added `scripts/docker-entrypoint.sh` to run `prisma migrate deploy` on startup.
|
||||||
- Der Spieler hat 6 Versuche.
|
- **Dockerfile**: Updated to use the entrypoint script.
|
||||||
- Der Fortschritt wird im LocalStorage des Browsers gespeichert.
|
- **Dependencies**: Moved `prisma` to `dependencies` in `package.json`.
|
||||||
|
|
||||||
## Technologien
|
## Verification Results
|
||||||
|
|
||||||
- **Framework**: Next.js 14 (App Router)
|
### Automated Build
|
||||||
- **Datenbank**: SQLite (via Prisma)
|
- `npm run build` passed successfully.
|
||||||
- **Styling**: Vanilla CSS (in `app/globals.css`)
|
- `npx prisma generate` passed.
|
||||||
- **State**: React Hooks + LocalStorage
|
|
||||||
|
|
||||||
## Wichtige Dateien
|
### Manual Verification Steps (Recommended)
|
||||||
|
1. **Admin Dashboard**:
|
||||||
- `app/page.tsx`: Hauptseite des Spiels.
|
* Go to `/admin`.
|
||||||
- `components/Game.tsx`: Die Spiellogik.
|
* Create a new genre (e.g., "Rock").
|
||||||
- `components/AudioPlayer.tsx`: Der Audio-Player mit Segment-Logik.
|
* Upload a song and assign "Rock" to it.
|
||||||
- `app/api/daily/route.ts`: API für das tägliche Rätsel.
|
* Edit an existing song and assign "Rock".
|
||||||
- `prisma/schema.prisma`: Datenbank-Schema.
|
2. **User Interface**:
|
||||||
|
* Go to `/`. Verify "Global" game works.
|
||||||
|
* Click "Rock". Verify URL changes to `/Rock`.
|
||||||
|
* Play the "Rock" game. Verify it picks a song tagged with "Rock".
|
||||||
|
* Verify stats are separate for Global and Rock.
|
||||||
|
3. **Deployment**:
|
||||||
|
* Deploy to Docker.
|
||||||
|
* Verify migrations run automatically on startup.
|
||||||
|
|||||||
Reference in New Issue
Block a user