| | 'use client'; |
| |
|
| | import { useEffect, useMemo, useState } from 'react'; |
| | import Link from 'next/link'; |
| | import { apiUrl } from '@/lib/constants'; |
| |
|
| | interface ClipboardSummary { |
| | id: string; |
| | createdAt: string; |
| | lastActivity: string; |
| | hasPassword: boolean; |
| | entryCount: number; |
| | fileCount: number; |
| | ttl: number | null; |
| | } |
| |
|
| | export default function AdminDashboard() { |
| | const [token, setToken] = useState(''); |
| | const [inputToken, setInputToken] = useState(''); |
| | const [clipboards, setClipboards] = useState<ClipboardSummary[]>([]); |
| | const [loading, setLoading] = useState(false); |
| | const [error, setError] = useState(''); |
| |
|
| | useEffect(() => { |
| | const saved = localStorage.getItem('adminToken'); |
| | if (saved) { |
| | setToken(saved); |
| | setInputToken(saved); |
| | } |
| | }, []); |
| |
|
| | useEffect(() => { |
| | if (token) { |
| | fetchClipboards(); |
| | } |
| | |
| | }, [token]); |
| |
|
| | const fetchClipboards = async () => { |
| | setLoading(true); |
| | setError(''); |
| |
|
| | try { |
| | const response = await fetch(`${apiUrl}/admin/clipboards`, { |
| | headers: { 'x-admin-token': token }, |
| | }); |
| |
|
| | if (response.status === 401) { |
| | setError('Invalid token. Please log in again.'); |
| | return; |
| | } |
| |
|
| | if (!response.ok) { |
| | throw new Error('Failed to fetch clipboards'); |
| | } |
| |
|
| | const data = await response.json(); |
| | setClipboards(data); |
| | } catch (err) { |
| | setError(err instanceof Error ? err.message : 'Unknown error'); |
| | } finally { |
| | setLoading(false); |
| | } |
| | }; |
| |
|
| | const handleLogin = () => { |
| | if (!inputToken) { |
| | setError('Please enter an admin token'); |
| | return; |
| | } |
| |
|
| | localStorage.setItem('adminToken', inputToken); |
| | setToken(inputToken); |
| | setError(''); |
| | }; |
| |
|
| | const handleDelete = async (roomCode: string) => { |
| | if (!confirm(`Delete clipboard ${roomCode}? This cannot be undone.`)) { |
| | return; |
| | } |
| |
|
| | try { |
| | const response = await fetch(`${apiUrl}/admin/clipboards/${roomCode}`, { |
| | method: 'DELETE', |
| | headers: { 'x-admin-token': token }, |
| | }); |
| |
|
| | if (!response.ok) { |
| | throw new Error('Failed to delete clipboard'); |
| | } |
| |
|
| | await fetchClipboards(); |
| | } catch (err) { |
| | setError(err instanceof Error ? err.message : 'Unknown error'); |
| | } |
| | }; |
| |
|
| | const tokenValid = useMemo(() => Boolean(token), [token]); |
| |
|
| | return ( |
| | <div className="w-full max-w-6xl mx-auto p-6 md:p-10"> |
| | <div className="flex items-center justify-between mb-6"> |
| | <div> |
| | <h1 className="text-3xl font-bold">Admin Dashboard</h1> |
| | <p className="text-text-secondary">Manage clipboards and inspect activity</p> |
| | </div> |
| | <Link href="/" className="btn btn-ghost"> |
| | Back to Home |
| | </Link> |
| | </div> |
| | |
| | <div className="card mb-6"> |
| | <div className="flex flex-col sm:flex-row sm:items-end gap-4"> |
| | <div className="flex-1"> |
| | <label className="block text-sm text-text-secondary mb-2">Admin Token</label> |
| | <input |
| | type="password" |
| | value={inputToken} |
| | onChange={(e) => setInputToken(e.target.value)} |
| | className="input w-full" |
| | placeholder="Enter admin token" |
| | /> |
| | </div> |
| | <button className="btn btn-primary self-start sm:self-auto" onClick={handleLogin}> |
| | {tokenValid ? 'Update Token' : 'Login'} |
| | </button> |
| | </div> |
| | {error && <p className="text-red-400 text-sm mt-2">{error}</p>} |
| | </div> |
| | |
| | {tokenValid && ( |
| | <div className="card"> |
| | <div className="flex items-center justify-between mb-4"> |
| | <h2 className="text-xl font-semibold">Clipboards</h2> |
| | <div className="flex gap-2"> |
| | <button className="btn btn-ghost" onClick={fetchClipboards} disabled={loading}> |
| | Refresh |
| | </button> |
| | </div> |
| | </div> |
| | {loading ? ( |
| | <p className="text-text-secondary">Loading clipboards...</p> |
| | ) : ( |
| | <div className="overflow-x-auto"> |
| | <table className="min-w-full text-sm"> |
| | <thead className="text-left text-text-secondary border-b border-surface-hover"> |
| | <tr> |
| | <th className="py-2 pr-4">ID</th> |
| | <th className="py-2 pr-4">Entries</th> |
| | <th className="py-2 pr-4">Files</th> |
| | <th className="py-2 pr-4">Password</th> |
| | <th className="py-2 pr-4">TTL</th> |
| | <th className="py-2 pr-4">Last Activity</th> |
| | <th className="py-2 pr-4 text-right">Actions</th> |
| | </tr> |
| | </thead> |
| | <tbody> |
| | {clipboards.length === 0 && ( |
| | <tr> |
| | <td className="py-4 text-text-secondary" colSpan={7}> |
| | No clipboards found. |
| | </td> |
| | </tr> |
| | )} |
| | {clipboards.map((clipboard) => ( |
| | <tr key={clipboard.id} className="border-b border-surface-hover"> |
| | <td className="py-3 pr-4 font-mono">{clipboard.id}</td> |
| | <td className="py-3 pr-4">{clipboard.entryCount}</td> |
| | <td className="py-3 pr-4">{clipboard.fileCount}</td> |
| | <td className="py-3 pr-4">{clipboard.hasPassword ? 'Yes' : 'No'}</td> |
| | <td className="py-3 pr-4">{formatTTL(clipboard.ttl)}</td> |
| | <td className="py-3 pr-4 text-text-secondary">{new Date(clipboard.lastActivity).toLocaleString()}</td> |
| | <td className="py-3 pr-4 text-right"> |
| | <div className="flex justify-end gap-2"> |
| | <Link href={`/admin/${clipboard.id}`} className="btn btn-secondary btn-sm"> |
| | View |
| | </Link> |
| | <button className="btn btn-ghost btn-sm" onClick={() => handleDelete(clipboard.id)}> |
| | Delete |
| | </button> |
| | </div> |
| | </td> |
| | </tr> |
| | ))} |
| | </tbody> |
| | </table> |
| | </div> |
| | )} |
| | </div> |
| | )} |
| | </div> |
| | ); |
| | } |
| |
|
| | function formatTTL(ttl: number | null): string { |
| | if (ttl === null) return 'None'; |
| | if (ttl < 60) return `${ttl}s`; |
| | if (ttl < 3600) return `${Math.round(ttl / 60)}m`; |
| | const hours = Math.round(ttl / 3600); |
| | return `${hours}h`; |
| | } |
| |
|