Walrus blob · testnet
On-chain registration not yet visible.
The aggregator served this blob, but we couldn't locate a matching BlobRegistered event in our scan window. It may not be certified yet, or live further back than we paged.
Lifecycle data is unavailable until the blob registration is visible on-chain.
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import toast from 'react-hot-toast';
import Navbar from '@/components/Navbar';
import type { User, Project } from '@/lib/types';
import { formatDistanceToNow } from 'date-fns';
export default function AdminPage() {
const { data: session, status } = useSession();
const router = useRouter();
const isAdmin = session?.user?.is_admin;
const [users, setUsers] = useState<User[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [tab, setTab] = useState<'users' | 'projects'>('users');
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newIsAdmin, setNewIsAdmin] = useState(false);
const [saving, setSaving] = useState(false);
const [newProjName, setNewProjName] = useState('');
const [newProjColor, setNewProjColor] = useState('#0F6E56');
useEffect(() => {
if (status === 'loading') return;
if (!isAdmin) router.replace('/board');
}, [status, isAdmin, router]);
const fetchData = useCallback(async () => {
setLoading(true);
const [ur, pr] = await Promise.all([fetch('/api/users'), fetch('/api/admin/projects')]);
setUsers(await ur.json());
setProjects(await pr.json());
setLoading(false);
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
async function addUser() {
if (!newName.trim() || !newEmail.trim()) { toast.error('Name and email required'); return; }
setSaving(true);
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, email: newEmail, is_admin: newIsAdmin }),
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error); }
toast.success('User added');
setNewName(''); setNewEmail(''); setNewIsAdmin(false);
fetchData();
} catch (err) { toast.error(String(err)); }
finally { setSaving(false); }
}
async function removeUser(id: number) {
if (!confirm('Remove this user?')) return;
await fetch(`/api/users/${id}`, { method: 'DELETE' });
toast.success('User removed');
fetchData();
}
async function toggleAdmin(user: User) {
await fetch(`/api/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: user.name, is_admin: !user.is_admin }),
});
toast.success('Updated');
fetchData();
}
async function addProject() {
if (!newProjName.trim()) { toast.error('Project name required'); return; }
setSaving(true);
try {
const res = await fetch('/api/admin/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newProjName, color: newProjColor }),
});
if (!res.ok) throw new Error('Failed');
toast.success('Project added');
setNewProjName(''); setNewProjColor('#0F6E56');
fetchData();
} catch { toast.error('Failed to add project'); }
finally { setSaving(false); }
}
async function removeProject(id: number) {
if (!confirm('Remove this project?')) return;
await fetch('/api/admin/projects', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
toast.success('Project removed');
fetchData();
}
if (!isAdmin && status !== 'loading') return null;
return (
<div className="min-h-screen flex flex-col bg-slate-100">
<Navbar/>
<main className="flex-1 max-w-4xl mx-auto w-full px-4 sm:px-6 py-8">
<div className="mb-8">
<h1 className="font-display text-3xl text-slate-900 mb-1">Admin panel</h1>
<p className="text-slate-500 text-sm">Manage users and projects</p>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-white border border-slate-200 p-1 rounded-xl w-fit shadow-sm">
{(['users', 'projects'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all capitalize ${
tab === t
? 'bg-lv-600 text-white shadow-sm'
: 'text-slate-500 hover:text-slate-900 hover:bg-slate-50'
}`}
>
{t}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-40">
<svg className="w-7 h-7 text-lv-500 animate-spin-slow" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
</div>
) : tab === 'users' ? (
<div className="space-y-5">
{/* Add user */}
<div className="card p-5">
<h2 className="text-sm font-semibold text-slate-800 mb-4">Add user</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div>
<label className="label">Full name</label>
<input className="input-field" value={newName} onChange={e => setNewName(e.target.value)} placeholder="Jane Smith"/>
</div>
<div>
<label className="label">Email (@levukalabs.com)</label>
<input className="input-field" value={newEmail} onChange={e => setNewEmail(e.target.value)} placeholder="jane@levukalabs.com" type="email"/>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={newIsAdmin} onChange={e => setNewIsAdmin(e.target.checked)} className="w-4 h-4 accent-lv-600 rounded"/>
<span className="text-sm text-slate-600">Grant admin access</span>
</label>
<button onClick={addUser} disabled={saving} className="btn-primary">Add user</button>
</div>
</div>
{/* User list */}
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="text-left px-5 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">Name</th>
<th className="text-left px-5 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden sm:table-cell">Email</th>
<th className="text-left px-5 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">Role</th>
<th className="text-left px-5 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide hidden md:table-cell">Added</th>
<th className="px-5 py-3"/>
</tr>
</thead>
<tbody>
{users.map((u, i) => (
<tr key={u.id} className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}>
<td className="px-5 py-3.5">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-lv-100 flex items-center justify-center shrink-0">
<span className="text-xs font-semibold text-lv-700">{u.name.charAt(0)}</span>
</div>
<span className="text-slate-800 font-medium">{u.name}</span>
</div>
</td>
<td className="px-5 py-3.5 text-slate-500 hidden sm:table-cell">{u.email}</td>
<td className="px-5 py-3.5">
<button
onClick={() => toggleAdmin(u)}
className={`badge cursor-pointer hover:opacity-80 transition-opacity ${
u.is_admin
? 'bg-lv-50 text-lv-700 border border-lv-200'
: 'bg-slate-100 text-slate-500 border border-slate-200'
}`}
>
{u.is_admin ? 'Admin' : 'Member'}
</button>
</td>
<td className="px-5 py-3.5 text-slate-400 text-xs hidden md:table-cell">
{formatDistanceToNow(new Date(u.created_at), { addSuffix: true })}
</td>
<td className="px-5 py-3.5 text-right">
<button onClick={() => removeUser(u.id)} className="text-slate-300 hover:text-red-500 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={5} className="px-5 py-8 text-center text-slate-400 text-sm">No users yet</td></tr>
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="space-y-5">
{/* Add project */}
<div className="card p-5">
<h2 className="text-sm font-semibold text-slate-800 mb-4">Add project</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
<div className="sm:col-span-2">
<label className="label">Project name</label>
<input className="input-field" value={newProjName} onChange={e => setNewProjName(e.target.value)} placeholder="My Project"/>
</div>
<div>
<label className="label">Badge color</label>
<div className="flex items-center gap-2">
<input type="color" value={newProjColor} onChange={e => setNewProjColor(e.target.value)} className="w-10 h-9 rounded-lg cursor-pointer border border-slate-200"/>
<input className="input-field font-mono text-xs" value={newProjColor} onChange={e => setNewProjColor(e.target.value)}/>
</div>
</div>
</div>
<div className="flex justify-end">
<button onClick={addProject} disabled={saving} className="btn-primary">Add project</button>
</div>
</div>
{/* Project list */}
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="text-left px-5 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">Project</th>
<th className="text-left px-5 py-3 text-slate-500 font-medium text-xs uppercase tracking-wide">Color</th>
<th className="px-5 py-3"/>
</tr>
</thead>
<tbody>
{projects.map((p, i) => (
<tr key={p.id} className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}>
<td className="px-5 py-3.5">
<span className="badge" style={{ background: p.color + '18', color: p.color, border: `1px solid ${p.color}33` }}>
{p.name}
</span>
</td>
<td className="px-5 py-3.5">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-slate-200" style={{ background: p.color }}/>
<span className="font-mono text-xs text-slate-500">{p.color}</span>
</div>
</td>
<td className="px-5 py-3.5 text-right">
<button onClick={() => removeProject(p.id)} className="text-slate-300 hover:text-red-500 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</td>
</tr>
))}
{projects.length === 0 && (
<tr><td colSpan={3} className="px-5 py-8 text-center text-slate-400 text-sm">No projects yet</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
</main>
</div>
);
}