Compare commits
12 Commits
326023a705
...
v0.1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce413cf6bc | ||
|
|
5102ca86cb | ||
|
|
eb3d2c86d7 | ||
|
|
883875b82a | ||
|
|
4c13817e77 | ||
|
|
35fe5f2d44 | ||
|
|
70501d626b | ||
|
|
41ce6c12ce | ||
|
|
a744393335 | ||
|
|
0ee3a48770 | ||
|
|
187774bce7 | ||
|
|
67cf85dc22 |
10
Dockerfile
10
Dockerfile
@@ -13,9 +13,16 @@ RUN npm ci
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install git to extract version information
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Extract version from git
|
||||
RUN git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
@@ -53,6 +60,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
# Create uploads directory and set permissions
|
||||
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
|
||||
|
||||
# Copy version file from builder
|
||||
COPY --from=builder /tmp/version.txt /app/version.txt
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -12,18 +12,22 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
|
||||
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
|
||||
- Bearbeitung von Metadaten.
|
||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am).
|
||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||
- **Cover Art:**
|
||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||
- Automatische Migration bestehender Songs.
|
||||
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden.
|
||||
- **Teilen-Funktion:**
|
||||
- Ergebnisse können als Emoji-Grid geteilt werden.
|
||||
- Stern-Symbol (⭐) bei korrekt beantworteter Bonusfrage.
|
||||
- Automatische Anpassung für Genre- und Special-Rätsel.
|
||||
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
||||
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
||||
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
||||
- **Genre-Management:**
|
||||
- Erstellen und Verwalten von Musik-Genres.
|
||||
- **Aktivierung/Deaktivierung:** Genres können aktiviert oder deaktiviert werden (deaktivierte Genres sind nicht auf der Startseite sichtbar und ihre Routen sind nicht erreichbar).
|
||||
- Manuelle Zuweisung von Genres zu Songs.
|
||||
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
||||
- Genre-spezifische tägliche Rätsel.
|
||||
|
||||
@@ -2,6 +2,7 @@ import Game from '@/components/Game';
|
||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -14,8 +15,21 @@ interface PageProps {
|
||||
export default async function GenrePage({ params }: PageProps) {
|
||||
const { genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
// Check if genre exists and is active
|
||||
const currentGenre = await prisma.genre.findUnique({
|
||||
where: { name: decodedGenre }
|
||||
});
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
|
||||
interface Special {
|
||||
@@ -21,6 +21,7 @@ interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
active: boolean;
|
||||
_count?: {
|
||||
songs: number;
|
||||
};
|
||||
@@ -47,9 +48,10 @@ interface Song {
|
||||
specials: Special[];
|
||||
averageRating: number;
|
||||
ratingCount: number;
|
||||
excludeFromGlobal: boolean;
|
||||
}
|
||||
|
||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
|
||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function AdminPage() {
|
||||
@@ -65,9 +67,11 @@ export default function AdminPage() {
|
||||
const [genres, setGenres] = useState<Genre[]>([]);
|
||||
const [newGenreName, setNewGenreName] = useState('');
|
||||
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
|
||||
const [newGenreActive, setNewGenreActive] = useState(true);
|
||||
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
|
||||
const [editGenreName, setEditGenreName] = useState('');
|
||||
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
|
||||
const [editGenreActive, setEditGenreActive] = useState(true);
|
||||
|
||||
// Specials state
|
||||
const [specials, setSpecials] = useState<Special[]>([]);
|
||||
@@ -95,10 +99,15 @@ export default function AdminPage() {
|
||||
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
|
||||
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
||||
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
|
||||
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
|
||||
|
||||
// Post-upload state
|
||||
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
||||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||||
const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false);
|
||||
|
||||
// Batch upload genre selection
|
||||
const [batchUploadGenreIds, setBatchUploadGenreIds] = useState<number[]>([]);
|
||||
|
||||
// AI Categorization state
|
||||
const [isCategorizing, setIsCategorizing] = useState(false);
|
||||
@@ -123,6 +132,7 @@ export default function AdminPage() {
|
||||
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
|
||||
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
|
||||
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Check for existing auth on mount
|
||||
useEffect(() => {
|
||||
@@ -196,11 +206,16 @@ export default function AdminPage() {
|
||||
const res = await fetch('/api/genres', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
|
||||
body: JSON.stringify({
|
||||
name: newGenreName,
|
||||
subtitle: newGenreSubtitle,
|
||||
active: newGenreActive
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewGenreName('');
|
||||
setNewGenreSubtitle('');
|
||||
setNewGenreActive(true);
|
||||
fetchGenres();
|
||||
} else {
|
||||
alert('Failed to create genre');
|
||||
@@ -211,6 +226,7 @@ export default function AdminPage() {
|
||||
setEditingGenreId(genre.id);
|
||||
setEditGenreName(genre.name);
|
||||
setEditGenreSubtitle(genre.subtitle || '');
|
||||
setEditGenreActive(genre.active !== undefined ? genre.active : true);
|
||||
};
|
||||
|
||||
const saveEditedGenre = async () => {
|
||||
@@ -221,7 +237,8 @@ export default function AdminPage() {
|
||||
body: JSON.stringify({
|
||||
id: editingGenreId,
|
||||
name: editGenreName,
|
||||
subtitle: editGenreSubtitle
|
||||
subtitle: editGenreSubtitle,
|
||||
active: editGenreActive
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -478,8 +495,11 @@ export default function AdminPage() {
|
||||
setUploadProgress({ current: i + 1, total: files.length });
|
||||
|
||||
try {
|
||||
console.log(`Uploading file ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('excludeFromGlobal', String(uploadExcludeFromGlobal));
|
||||
|
||||
const res = await fetch('/api/songs', {
|
||||
method: 'POST',
|
||||
@@ -487,8 +507,11 @@ export default function AdminPage() {
|
||||
body: formData,
|
||||
});
|
||||
|
||||
console.log(`Response status for ${file.name}: ${res.status}`);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log(`Upload successful for ${file.name}:`, data);
|
||||
results.push({
|
||||
filename: file.name,
|
||||
success: true,
|
||||
@@ -498,6 +521,7 @@ export default function AdminPage() {
|
||||
} else if (res.status === 409) {
|
||||
// Duplicate detected
|
||||
const data = await res.json();
|
||||
console.log(`Duplicate detected for ${file.name}:`, data);
|
||||
results.push({
|
||||
filename: file.name,
|
||||
success: false,
|
||||
@@ -506,17 +530,20 @@ export default function AdminPage() {
|
||||
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
|
||||
});
|
||||
} else {
|
||||
const errorText = await res.text();
|
||||
console.error(`Upload failed for ${file.name} (${res.status}):`, errorText);
|
||||
results.push({
|
||||
filename: file.name,
|
||||
success: false,
|
||||
error: 'Upload failed'
|
||||
error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Network error for ${file.name}:`, error);
|
||||
results.push({
|
||||
filename: file.name,
|
||||
success: false,
|
||||
error: 'Network error'
|
||||
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -524,6 +551,28 @@ export default function AdminPage() {
|
||||
setUploadResults(results);
|
||||
setFiles([]);
|
||||
setIsUploading(false);
|
||||
|
||||
// Assign genres to successfully uploaded songs
|
||||
if (batchUploadGenreIds.length > 0) {
|
||||
const successfulUploads = results.filter(r => r.success && r.song);
|
||||
for (const result of successfulUploads) {
|
||||
try {
|
||||
await fetch('/api/songs', {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
id: result.song.id,
|
||||
title: result.song.title,
|
||||
artist: result.song.artist,
|
||||
genreIds: batchUploadGenreIds
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to assign genres to ${result.song.title}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchSongs();
|
||||
fetchGenres();
|
||||
fetchSpecials(); // Update special counts
|
||||
@@ -540,6 +589,13 @@ export default function AdminPage() {
|
||||
if (failedCount > 0) {
|
||||
msg += `\n❌ ${failedCount} failed`;
|
||||
}
|
||||
if (batchUploadGenreIds.length > 0) {
|
||||
const selectedGenreNames = genres
|
||||
.filter(g => batchUploadGenreIds.includes(g.id))
|
||||
.map(g => g.name)
|
||||
.join(', ');
|
||||
msg += `\n🏷️ Assigned genres: ${selectedGenreNames}`;
|
||||
}
|
||||
msg += '\n\n🤖 Starting auto-categorization...';
|
||||
setMessage(msg);
|
||||
// Small delay to let user see the message
|
||||
@@ -553,32 +609,81 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
if (!isDragging) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Prevent flickering when dragging over children
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files).filter(
|
||||
file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3')
|
||||
);
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
|
||||
if (droppedFiles.length > 0) {
|
||||
setFiles(droppedFiles);
|
||||
// Validate file types
|
||||
const validFiles: File[] = [];
|
||||
const invalidFiles: string[] = [];
|
||||
|
||||
droppedFiles.forEach(file => {
|
||||
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
|
||||
}
|
||||
});
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
setFiles(validFiles);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files));
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
|
||||
// Validate file types
|
||||
const validFiles: File[] = [];
|
||||
const invalidFiles: string[] = [];
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
|
||||
}
|
||||
});
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
setFiles(validFiles);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -615,6 +720,7 @@ export default function AdminPage() {
|
||||
setEditReleaseYear(song.releaseYear || '');
|
||||
setEditGenreIds(song.genres.map(g => g.id));
|
||||
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
|
||||
setEditExcludeFromGlobal(song.excludeFromGlobal || false);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
@@ -624,6 +730,7 @@ export default function AdminPage() {
|
||||
setEditReleaseYear('');
|
||||
setEditGenreIds([]);
|
||||
setEditSpecialIds([]);
|
||||
setEditExcludeFromGlobal(false);
|
||||
};
|
||||
|
||||
const saveEditing = async (id: number) => {
|
||||
@@ -636,7 +743,8 @@ export default function AdminPage() {
|
||||
artist: editArtist,
|
||||
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
|
||||
genreIds: editGenreIds,
|
||||
specialIds: editSpecialIds
|
||||
specialIds: editSpecialIds,
|
||||
excludeFromGlobal: editExcludeFromGlobal
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -739,6 +847,8 @@ export default function AdminPage() {
|
||||
} else if (selectedGenreFilter === 'daily') {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
|
||||
} else if (selectedGenreFilter === 'no-global') {
|
||||
matchesFilter = song.excludeFromGlobal === true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,7 +856,7 @@ export default function AdminPage() {
|
||||
});
|
||||
|
||||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||||
// Handle numeric sorting for ID and Release Year
|
||||
// Handle numeric sorting for ID, Release Year, Activations, and Rating
|
||||
if (sortField === 'id') {
|
||||
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
||||
}
|
||||
@@ -755,6 +865,12 @@ export default function AdminPage() {
|
||||
const yearB = b.releaseYear || 0;
|
||||
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
|
||||
}
|
||||
if (sortField === 'activations') {
|
||||
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
|
||||
}
|
||||
if (sortField === 'averageRating') {
|
||||
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
|
||||
}
|
||||
|
||||
// String sorting for other fields
|
||||
const valA = String(a[sortField]).toLowerCase();
|
||||
@@ -910,7 +1026,7 @@ export default function AdminPage() {
|
||||
{/* 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' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newGenreName}
|
||||
@@ -927,12 +1043,21 @@ export default function AdminPage() {
|
||||
className="form-input"
|
||||
style={{ maxWidth: '300px' }}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newGenreActive}
|
||||
onChange={e => setNewGenreActive(e.target.checked)}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<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',
|
||||
background: genre.active ? '#f3f4f6' : '#fee2e2',
|
||||
opacity: genre.active ? 1 : 0.8,
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
@@ -959,6 +1084,16 @@ export default function AdminPage() {
|
||||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
||||
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', paddingBottom: '0.5rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editGenreActive}
|
||||
onChange={e => setEditGenreActive(e.target.checked)}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<button onClick={saveEditedGenre} className="btn-primary">Save</button>
|
||||
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
|
||||
</div>
|
||||
@@ -1052,6 +1187,7 @@ export default function AdminPage() {
|
||||
<form onSubmit={handleBatchUpload}>
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -1065,7 +1201,7 @@ export default function AdminPage() {
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
|
||||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
||||
@@ -1075,7 +1211,7 @@ export default function AdminPage() {
|
||||
or click to browse
|
||||
</p>
|
||||
<input
|
||||
id="file-input"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/mpeg"
|
||||
multiple
|
||||
@@ -1115,6 +1251,63 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
|
||||
Assign Genres (optional)
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{genres.map(genre => (
|
||||
<label
|
||||
key={genre.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: batchUploadGenreIds.includes(genre.id) ? '#dbeafe' : '#f3f4f6',
|
||||
border: batchUploadGenreIds.includes(genre.id) ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchUploadGenreIds.includes(genre.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
|
||||
} else {
|
||||
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
|
||||
}
|
||||
}}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
{genre.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
|
||||
Selected genres will be assigned to all uploaded songs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadExcludeFromGlobal}
|
||||
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
|
||||
style={{ width: '1.25rem', height: '1.25rem' }}
|
||||
/>
|
||||
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
|
||||
</label>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
|
||||
If checked, these songs will only appear in Genre or Special puzzles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
@@ -1235,6 +1428,7 @@ export default function AdminPage() {
|
||||
>
|
||||
<option value="">All Content</option>
|
||||
<option value="daily">📅 Song of the Day</option>
|
||||
<option value="no-global">🚫 No Global</option>
|
||||
<optgroup label="Genres">
|
||||
<option value="genre:-1">No Genre</option>
|
||||
{genres.map(genre => (
|
||||
@@ -1300,8 +1494,18 @@ export default function AdminPage() {
|
||||
>
|
||||
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th style={{ padding: '0.75rem' }}>Activations</th>
|
||||
<th style={{ padding: '0.75rem' }}>Rating</th>
|
||||
<th
|
||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('activations')}
|
||||
>
|
||||
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('averageRating')}
|
||||
>
|
||||
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th style={{ padding: '0.75rem' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1377,6 +1581,16 @@ export default function AdminPage() {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editExcludeFromGlobal}
|
||||
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
|
||||
/>
|
||||
Exclude from Global
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||||
@@ -1416,6 +1630,24 @@ export default function AdminPage() {
|
||||
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
|
||||
|
||||
{song.excludeFromGlobal && (
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
<span style={{
|
||||
background: '#fee2e2',
|
||||
color: '#991b1b',
|
||||
padding: '0.1rem 0.4rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.7rem',
|
||||
border: '1px solid #fecaca',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem'
|
||||
}}>
|
||||
🚫 No Global
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily Puzzle Badges */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
||||
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
|
||||
@@ -1605,73 +1837,7 @@ export default function AdminPage() {
|
||||
☢️ Rebuild Database
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: '1rem', borderTop: '1px solid #eee', paddingTop: '1rem' }}>
|
||||
<p style={{ marginBottom: '1rem', color: '#666' }}>
|
||||
Update release years for all songs using the iTunes API. This will overwrite existing years.
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (window.confirm('This will scan all songs and overwrite their release years using data from iTunes. This process may take a while.\n\nContinue?')) {
|
||||
try {
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
let totalUpdated = 0;
|
||||
let totalSkipped = 0;
|
||||
let totalFailed = 0;
|
||||
let totalProcessed = 0;
|
||||
let totalSongs = 0;
|
||||
|
||||
setMessage('Initializing release year refresh...');
|
||||
|
||||
while (hasMore) {
|
||||
const res = await fetch('/api/admin/refresh-years', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ offset, limit: 10 }) // Process 10 at a time
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Batch request failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
totalUpdated += data.updated;
|
||||
totalSkipped += data.skipped;
|
||||
totalFailed += data.failed;
|
||||
totalProcessed += data.processed;
|
||||
totalSongs = data.total;
|
||||
hasMore = data.hasMore;
|
||||
offset = data.nextOffset;
|
||||
|
||||
setMessage(`Processing... ${totalProcessed} / ${totalSongs} songs.\nUpdated: ${totalUpdated} | Skipped: ${totalSkipped} | Failed: ${totalFailed}`);
|
||||
}
|
||||
|
||||
const finalMsg = `✅ Completed!\nTotal Processed: ${totalProcessed}\nUpdated: ${totalUpdated}\nSkipped: ${totalSkipped}\nFailed: ${totalFailed}`;
|
||||
alert(finalMsg);
|
||||
setMessage(finalMsg);
|
||||
fetchSongs(); // Refresh the table
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Process failed due to network error or timeout.');
|
||||
setMessage('Refresh failed.');
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#f59e0b',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
🔄 Refresh Release Years (iTunes)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getReleaseYearFromItunes } from '@/lib/itunes';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Helper to delay execution to avoid rate limits
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { offset = 0, limit = 20 } = await request.json();
|
||||
|
||||
// Fetch batch of songs
|
||||
const songs = await prisma.song.findMany({
|
||||
select: { id: true, title: true, artist: true },
|
||||
orderBy: { id: 'asc' },
|
||||
skip: offset,
|
||||
take: limit
|
||||
});
|
||||
|
||||
const totalSongs = await prisma.song.count();
|
||||
|
||||
console.log(`Processing batch: offset=${offset}, limit=${limit}, found=${songs.length}`);
|
||||
|
||||
let updatedCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
const results = [];
|
||||
|
||||
for (const song of songs) {
|
||||
try {
|
||||
// Rate limiting: wait 2000ms between requests to be safe (iTunes can be strict)
|
||||
await sleep(2000);
|
||||
|
||||
const year = await getReleaseYearFromItunes(song.artist, song.title);
|
||||
|
||||
if (year) {
|
||||
await prisma.song.update({
|
||||
where: { id: song.id },
|
||||
data: { releaseYear: year }
|
||||
});
|
||||
updatedCount++;
|
||||
results.push({ id: song.id, title: song.title, artist: song.artist, year, status: 'updated' });
|
||||
} else {
|
||||
skippedCount++;
|
||||
results.push({ id: song.id, title: song.title, artist: song.artist, status: 'not_found' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to update year for ${song.title} - ${song.artist}:`, error);
|
||||
failedCount++;
|
||||
results.push({ id: song.id, title: song.title, artist: song.artist, status: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processed: songs.length,
|
||||
total: totalSongs,
|
||||
hasMore: offset + songs.length < totalSongs,
|
||||
nextOffset: offset + songs.length,
|
||||
updated: updatedCount,
|
||||
failed: failedCount,
|
||||
skipped: skippedCount,
|
||||
results
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing release years:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export async function POST(request: Request) {
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { name, subtitle } = await request.json();
|
||||
const { name, subtitle, active } = await request.json();
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||
@@ -36,7 +36,8 @@ export async function POST(request: Request) {
|
||||
const genre = await prisma.genre.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
subtitle: subtitle ? subtitle.trim() : null
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
active: active !== undefined ? active : true
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,7 +77,7 @@ export async function PUT(request: Request) {
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id, name, subtitle } = await request.json();
|
||||
const { id, name, subtitle, active } = await request.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
@@ -86,7 +87,8 @@ export async function PUT(request: Request) {
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(name && { name: name.trim() }),
|
||||
subtitle: subtitle ? subtitle.trim() : null // Allow clearing subtitle if empty string passed? Or just update if provided? Let's assume null/empty string clears it.
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
...(active !== undefined && { active })
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Configure route to handle large file uploads
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||
|
||||
export async function GET() {
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -37,23 +41,35 @@ export async function GET() {
|
||||
specials: song.specials.map(ss => ss.special),
|
||||
averageRating: song.averageRating,
|
||||
ratingCount: song.ratingCount,
|
||||
excludeFromGlobal: song.excludeFromGlobal,
|
||||
}));
|
||||
|
||||
return NextResponse.json(songsWithActivations);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
console.log('[UPLOAD] Starting song upload request');
|
||||
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
if (authError) {
|
||||
console.log('[UPLOAD] Authentication failed');
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[UPLOAD] Parsing form data...');
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
let title = '';
|
||||
let artist = '';
|
||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
|
||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
||||
|
||||
if (!file) {
|
||||
console.error('[UPLOAD] No file provided');
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -81,6 +97,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
|
||||
|
||||
// Validate and extract metadata from file
|
||||
let metadata;
|
||||
@@ -208,10 +225,9 @@ export async function POST(request: Request) {
|
||||
console.error('Failed to extract cover image:', e);
|
||||
}
|
||||
|
||||
// Fetch release year (iTunes first, then MusicBrainz)
|
||||
// Fetch release year from iTunes
|
||||
let releaseYear = null;
|
||||
try {
|
||||
// Try iTunes first
|
||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||
releaseYear = await getReleaseYearFromItunes(artist, title);
|
||||
|
||||
@@ -229,6 +245,7 @@ export async function POST(request: Request) {
|
||||
filename,
|
||||
coverImage,
|
||||
releaseYear,
|
||||
excludeFromGlobal,
|
||||
},
|
||||
include: { genres: true, specials: true }
|
||||
});
|
||||
@@ -249,7 +266,7 @@ export async function PUT(request: Request) {
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json();
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||
|
||||
if (!id || !title || !artist) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||
@@ -262,6 +279,10 @@ export async function PUT(request: Request) {
|
||||
data.releaseYear = releaseYear;
|
||||
}
|
||||
|
||||
if (excludeFromGlobal !== undefined) {
|
||||
data.excludeFromGlobal = excludeFromGlobal;
|
||||
}
|
||||
|
||||
if (genreIds) {
|
||||
data.genres = {
|
||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||
|
||||
47
app/api/version/route.ts
Normal file
47
app/api/version/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// First check if version is set via environment variable (Docker build)
|
||||
if (process.env.APP_VERSION) {
|
||||
return NextResponse.json({ version: process.env.APP_VERSION });
|
||||
}
|
||||
|
||||
// Try to get the git tag/version
|
||||
let version = 'dev';
|
||||
|
||||
try {
|
||||
// First try to get the exact tag if we're on a tagged commit
|
||||
version = execSync('git describe --tags --exact-match 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd()
|
||||
}).trim();
|
||||
} catch {
|
||||
try {
|
||||
// If not on a tag, get the latest tag with commit info
|
||||
version = execSync('git describe --tags --always 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd()
|
||||
}).trim();
|
||||
} catch {
|
||||
// If git is not available or no tags exist, try to get commit hash
|
||||
try {
|
||||
const hash = execSync('git rev-parse --short HEAD 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd()
|
||||
}).trim();
|
||||
version = `dev-${hash}`;
|
||||
} catch {
|
||||
// Fallback to just 'dev' if git is not available
|
||||
version = 'dev';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ version });
|
||||
} catch (error) {
|
||||
console.error('Error getting version:', error);
|
||||
return NextResponse.json({ version: 'unknown' });
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const viewport: Viewport = {
|
||||
};
|
||||
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -36,15 +37,7 @@ export default function RootLayout({
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Vibe coded with ☕ and 🍺 by{' '}
|
||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||
@elpatron@digitalcourage.social
|
||||
</a>
|
||||
{' '}- for personal use among friends only!
|
||||
</p>
|
||||
</footer>
|
||||
<AppFooter />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,10 @@ const prisma = new PrismaClient();
|
||||
|
||||
export default async function Home() {
|
||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||
|
||||
const now = new Date();
|
||||
|
||||
34
components/AppFooter.tsx
Normal file
34
components/AppFooter.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function AppFooter() {
|
||||
const [version, setVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/version')
|
||||
.then(res => res.json())
|
||||
.then(data => setVersion(data.version))
|
||||
.catch(() => setVersion(''));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Vibe coded with ☕ and 🍺 by{' '}
|
||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||
@elpatron@digitalcourage.social
|
||||
</a>
|
||||
{' '}- for personal use among friends only!
|
||||
{version && (
|
||||
<>
|
||||
{' '}·{' '}
|
||||
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
|
||||
{version}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -173,7 +173,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
|
||||
const speaker = hasWon ? '🔉' : '🔇';
|
||||
const genreText = genre ? `Genre: ${genre}\n` : '';
|
||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
||||
|
||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||
if (genre) {
|
||||
@@ -184,7 +185,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}
|
||||
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
@@ -332,7 +333,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
/>
|
||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
{dailyPuzzle.releaseYear && gameState.yearGuessed !== null && (
|
||||
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||
)}
|
||||
<audio controls style={{ width: '100%' }}>
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
// Get songs available for this genre
|
||||
const whereClause = genreId
|
||||
? { genres: { some: { id: genreId } } }
|
||||
: {}; // Global puzzle picks from ALL songs
|
||||
: { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
|
||||
|
||||
const allSongs = await prisma.song.findMany({
|
||||
where: whereClause,
|
||||
|
||||
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
middlewareClientMaxBodySize: '50mb',
|
||||
},
|
||||
env: {
|
||||
TZ: process.env.TZ || 'Europe/Berlin',
|
||||
|
||||
BIN
prisma/dev.db.bak
Normal file
BIN
prisma/dev.db.bak
Normal file
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Song" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"artist" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"coverImage" TEXT,
|
||||
"releaseYear" INTEGER,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"averageRating" REAL NOT NULL DEFAULT 0,
|
||||
"ratingCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"excludeFromGlobal" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
INSERT INTO "new_Song" ("artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title") SELECT "artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title" FROM "Song";
|
||||
DROP TABLE "Song";
|
||||
ALTER TABLE "new_Song" RENAME TO "Song";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Genre" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"subtitle" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
INSERT INTO "new_Genre" ("id", "name", "subtitle") SELECT "id", "name", "subtitle" FROM "Genre";
|
||||
DROP TABLE "Genre";
|
||||
ALTER TABLE "new_Genre" RENAME TO "Genre";
|
||||
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -23,12 +23,14 @@ model Song {
|
||||
specials SpecialSong[]
|
||||
averageRating Float @default(0)
|
||||
ratingCount Int @default(0)
|
||||
excludeFromGlobal Boolean @default(false)
|
||||
}
|
||||
|
||||
model Genre {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
subtitle String?
|
||||
active Boolean @default(true)
|
||||
songs Song[]
|
||||
dailyPuzzles DailyPuzzle[]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Export version if available
|
||||
if [ -f /app/version.txt ]; then
|
||||
export APP_VERSION=$(cat /app/version.txt)
|
||||
echo "App version: $APP_VERSION"
|
||||
fi
|
||||
|
||||
echo "Starting deployment..."
|
||||
|
||||
# Run migrations
|
||||
|
||||
Reference in New Issue
Block a user