feat: implement admin dashboard with password-protected event listing
This commit is contained in:
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom';
|
|||||||
import CreateEvent from './components/CreateEvent';
|
import CreateEvent from './components/CreateEvent';
|
||||||
import EventPoll from './components/EventPoll';
|
import EventPoll from './components/EventPoll';
|
||||||
import AnalyticsDashboard from './components/AnalyticsDashboard';
|
import AnalyticsDashboard from './components/AnalyticsDashboard';
|
||||||
|
import AdminDashboard from './components/AdminDashboard';
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
return (
|
return (
|
||||||
@@ -27,6 +28,7 @@ function App() {
|
|||||||
<Route path="/" element={<CreateEvent />} />
|
<Route path="/" element={<CreateEvent />} />
|
||||||
<Route path="/event/:id" element={<EventPoll />} />
|
<Route path="/event/:id" element={<EventPoll />} />
|
||||||
<Route path="/event/:id/analytics" element={<AnalyticsDashboard />} />
|
<Route path="/event/:id/analytics" element={<AnalyticsDashboard />} />
|
||||||
|
<Route path="/admin" element={<AdminDashboard />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
148
client/src/components/AdminDashboard.jsx
Normal file
148
client/src/components/AdminDashboard.jsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [events, setEvents] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/events', {
|
||||||
|
headers: { 'x-admin-password': password },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
setError('Invalid password');
|
||||||
|
setEvents(null);
|
||||||
|
} else if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEvents(data.events);
|
||||||
|
} else {
|
||||||
|
setError('Failed to load events');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Network error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!events) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="w-full max-w-sm mx-auto"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-xl shadow-stripe p-6 sm:p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-6">Admin Login</h1>
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter admin password"
|
||||||
|
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all text-slate-800"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-500 text-sm">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-slate-900 hover:bg-slate-800 text-white font-medium py-2.5 rounded-lg shadow-sm hover:shadow-md transition-all active:scale-[0.98] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Checking...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-xl shadow-stripe p-6 sm:p-8">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">All Events</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setEvents(null)}
|
||||||
|
className="text-sm text-slate-500 hover:text-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-center py-8">No events yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="pb-3 text-xs font-bold text-slate-400 uppercase tracking-wider">Event</th>
|
||||||
|
<th className="pb-3 text-xs font-bold text-slate-400 uppercase tracking-wider">Dates</th>
|
||||||
|
<th className="pb-3 text-xs font-bold text-slate-400 uppercase tracking-wider">Links</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map((event) => (
|
||||||
|
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="font-medium text-slate-800">{event.name}</div>
|
||||||
|
{event.description && (
|
||||||
|
<div className="text-sm text-slate-500 truncate max-w-xs">{event.description}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-600">
|
||||||
|
{format(parseISO(event.start_date), 'MMM d')} - {format(parseISO(event.end_date), 'MMM d, yyyy')}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<a
|
||||||
|
href={`${origin}/event/${event.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary hover:text-primary-hover font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Invite
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`${origin}/event/${event.id}/analytics`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-slate-500 hover:text-slate-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Analytics
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,4 +108,22 @@ router.get('/events/:id/analytics', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin: Get All Events (password protected)
|
||||||
|
router.get('/admin/events', (req, res) => {
|
||||||
|
const password = req.headers['x-admin-password'];
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || '123456';
|
||||||
|
|
||||||
|
if (password !== adminPassword) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = db.prepare('SELECT id, name, description, start_date, end_date, created_at FROM events ORDER BY created_at DESC').all();
|
||||||
|
res.json({ events });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to retrieve events' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user