feat(song): add option to exclude songs from global visibility and improve admin upload validation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
|
||||
interface Special {
|
||||
@@ -47,6 +47,7 @@ interface Song {
|
||||
specials: Special[];
|
||||
averageRating: number;
|
||||
ratingCount: number;
|
||||
excludeFromGlobal: boolean;
|
||||
}
|
||||
|
||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
|
||||
@@ -95,10 +96,12 @@ 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);
|
||||
|
||||
// AI Categorization state
|
||||
const [isCategorizing, setIsCategorizing] = useState(false);
|
||||
@@ -123,6 +126,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(() => {
|
||||
@@ -478,8 +482,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 +494,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 +508,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 +517,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'}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -553,32 +567,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 +678,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 +688,7 @@ export default function AdminPage() {
|
||||
setEditReleaseYear('');
|
||||
setEditGenreIds([]);
|
||||
setEditSpecialIds([]);
|
||||
setEditExcludeFromGlobal(false);
|
||||
};
|
||||
|
||||
const saveEditing = async (id: number) => {
|
||||
@@ -636,7 +701,8 @@ export default function AdminPage() {
|
||||
artist: editArtist,
|
||||
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
|
||||
genreIds: editGenreIds,
|
||||
specialIds: editSpecialIds
|
||||
specialIds: editSpecialIds,
|
||||
excludeFromGlobal: editExcludeFromGlobal
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -739,6 +805,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,6 +1120,7 @@ export default function AdminPage() {
|
||||
<form onSubmit={handleBatchUpload}>
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -1065,7 +1134,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 +1144,7 @@ export default function AdminPage() {
|
||||
or click to browse
|
||||
</p>
|
||||
<input
|
||||
id="file-input"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/mpeg"
|
||||
multiple
|
||||
@@ -1115,6 +1184,21 @@ export default function AdminPage() {
|
||||
</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 +1319,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 => (
|
||||
@@ -1377,6 +1462,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 +1511,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 => {
|
||||
|
||||
Reference in New Issue
Block a user