Initial commit: Hördle Web App
This commit is contained in:
281
app/admin/page.tsx
Normal file
281
app/admin/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Song {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type SortField = 'title' | 'artist';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function AdminPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [artist, setArtist] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
|
||||
// Edit state
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editArtist, setEditArtist] = useState('');
|
||||
|
||||
// Sort state
|
||||
const [sortField, setSortField] = useState<SortField>('artist');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
const handleLogin = async () => {
|
||||
const res = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setIsAuthenticated(true);
|
||||
fetchSongs();
|
||||
} else {
|
||||
alert('Wrong password');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSongs = async () => {
|
||||
const res = await fetch('/api/songs');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSongs(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (title) formData.append('title', title);
|
||||
if (artist) formData.append('artist', artist);
|
||||
|
||||
setMessage('Uploading...');
|
||||
const res = await fetch('/api/songs', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage('Song uploaded successfully!');
|
||||
setTitle('');
|
||||
setArtist('');
|
||||
setFile(null);
|
||||
fetchSongs();
|
||||
} else {
|
||||
setMessage('Upload failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (song: Song) => {
|
||||
setEditingId(song.id);
|
||||
setEditTitle(song.title);
|
||||
setEditArtist(song.artist);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingId(null);
|
||||
setEditTitle('');
|
||||
setEditArtist('');
|
||||
};
|
||||
|
||||
const saveEditing = async (id: number) => {
|
||||
const res = await fetch('/api/songs', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, title: editTitle, artist: editArtist }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setEditingId(null);
|
||||
fetchSongs();
|
||||
} else {
|
||||
alert('Failed to update song');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedSongs = [...songs].sort((a, b) => {
|
||||
const valA = a[sortField].toLowerCase();
|
||||
const valB = b[sortField].toLowerCase();
|
||||
|
||||
if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="container" style={{ justifyContent: 'center' }}>
|
||||
<h1 className="title" style={{ marginBottom: '1rem', fontSize: '1.5rem' }}>Admin Login</h1>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ marginBottom: '1rem', maxWidth: '300px' }}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<button onClick={handleLogin} className="btn-primary">Login</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-container">
|
||||
<h1 className="title" style={{ marginBottom: '2rem' }}>Admin Dashboard</h1>
|
||||
|
||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload New Song</h2>
|
||||
<form onSubmit={handleUpload}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">MP3 File (Required)</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/mpeg"
|
||||
onChange={e => setFile(e.target.files?.[0] || null)}
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Title (Optional - extracted from file if empty)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Artist (Optional - extracted from file if empty)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={artist}
|
||||
onChange={e => setArtist(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary">
|
||||
Upload Song
|
||||
</button>
|
||||
{message && <p style={{ textAlign: 'center', marginTop: '0.5rem' }}>{message}</p>}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Song Library</h2>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
||||
<th style={{ padding: '0.75rem' }}>ID</th>
|
||||
<th
|
||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('title')}
|
||||
>
|
||||
Title {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('artist')}
|
||||
>
|
||||
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th style={{ padding: '0.75rem' }}>Filename</th>
|
||||
<th style={{ padding: '0.75rem' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedSongs.map(song => (
|
||||
<tr key={song.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={{ padding: '0.75rem' }}>{song.id}</td>
|
||||
|
||||
{editingId === song.id ? (
|
||||
<>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ padding: '0.25rem' }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editArtist}
|
||||
onChange={e => setEditArtist(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ padding: '0.25rem' }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.filename}</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => saveEditing(song.id)}
|
||||
style={{ color: 'green', cursor: 'pointer', border: 'none', background: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
style={{ color: 'red', cursor: 'pointer', border: 'none', background: 'none' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
|
||||
<td style={{ padding: '0.75rem' }}>{song.artist}</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666' }}>{song.filename}</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<button
|
||||
onClick={() => startEditing(song)}
|
||||
style={{ color: 'blue', cursor: 'pointer', border: 'none', background: 'none', textDecoration: 'underline' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{songs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
||||
No songs uploaded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user