Spaces:
Running
Running
| 'use client'; | |
| import { useState, useEffect } from 'react'; | |
| import Link from 'next/link'; | |
| import { adminFetch } from '@/lib/auth/admin-fetch'; | |
| interface User { | |
| id: string; | |
| username: string; | |
| createdAt: string; | |
| conversationCount: number; | |
| messageCount: number; | |
| lastActiveAt: string | null; | |
| } | |
| export default function AdminUsersPage() { | |
| const [users, setUsers] = useState<User[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [search, setSearch] = useState(''); | |
| const [sortBy, setSortBy] = useState<'username' | 'createdAt'>('createdAt'); | |
| const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); | |
| const [page, setPage] = useState(1); | |
| const [totalPages, setTotalPages] = useState(1); | |
| const [total, setTotal] = useState(0); | |
| useEffect(() => { | |
| fetchUsers(); | |
| }, [search, sortBy, sortOrder, page]); | |
| const fetchUsers = async () => { | |
| try { | |
| setLoading(true); | |
| const params = new URLSearchParams({ | |
| page: page.toString(), | |
| limit: '50', | |
| sortBy, | |
| sortOrder, | |
| ...(search && { search }), | |
| }); | |
| const response = await adminFetch(`/api/admin/users?${params}`); | |
| if (!response.ok) { | |
| throw new Error('Failed to fetch users'); | |
| } | |
| const data = await response.json(); | |
| setUsers(data.users); | |
| setTotalPages(data.pagination.totalPages); | |
| setTotal(data.pagination.total); | |
| setError(null); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : 'Unknown error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleSearchChange = (value: string) => { | |
| setSearch(value); | |
| setPage(1); // Reset to first page on search | |
| }; | |
| return ( | |
| <div className="p-4 md:p-8"> | |
| {/* Header */} | |
| <div className="mb-6"> | |
| <h1 className="text-3xl font-bold text-gray-900 mb-2">Users Management</h1> | |
| <p className="text-gray-600">View and search all registered users</p> | |
| </div> | |
| {/* Filters Bar */} | |
| <div className="bg-white rounded-lg shadow p-4 mb-6"> | |
| <div className="flex flex-col md:flex-row gap-4"> | |
| {/* Search */} | |
| <div className="flex-1"> | |
| <input | |
| type="text" | |
| placeholder="Search by username..." | |
| value={search} | |
| onChange={e => handleSearchChange(e.target.value)} | |
| className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" | |
| /> | |
| </div> | |
| {/* Sort By */} | |
| <select | |
| value={sortBy} | |
| onChange={e => setSortBy(e.target.value as 'username' | 'createdAt')} | |
| className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" | |
| > | |
| <option value="createdAt">Sort by Date</option> | |
| <option value="username">Sort by Username</option> | |
| </select> | |
| {/* Sort Order */} | |
| <button | |
| onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')} | |
| className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-2" | |
| > | |
| {sortOrder === 'asc' ? '↑' : '↓'} | |
| {sortOrder === 'asc' ? 'Ascending' : 'Descending'} | |
| </button> | |
| </div> | |
| {/* Results Count */} | |
| <div className="mt-4 text-sm text-gray-600"> | |
| Showing {users.length} of {total} users | |
| </div> | |
| </div> | |
| {/* Loading State */} | |
| {loading && ( | |
| <div className="text-center py-12"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> | |
| <p className="mt-4 text-gray-600">Loading users...</p> | |
| </div> | |
| )} | |
| {/* Error State */} | |
| {error && ( | |
| <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"> | |
| <h3 className="text-red-800 font-semibold mb-1">Error</h3> | |
| <p className="text-red-700">{error}</p> | |
| <button | |
| onClick={fetchUsers} | |
| className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700" | |
| > | |
| Retry | |
| </button> | |
| </div> | |
| )} | |
| {/* Users Table (Desktop) */} | |
| {!loading && !error && ( | |
| <> | |
| <div className="hidden md:block bg-white rounded-lg shadow overflow-hidden"> | |
| <table className="w-full"> | |
| <thead className="bg-gray-50 border-b border-gray-200"> | |
| <tr> | |
| <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"> | |
| Username | |
| </th> | |
| <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"> | |
| Registered | |
| </th> | |
| <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"> | |
| Conversations | |
| </th> | |
| <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"> | |
| Messages | |
| </th> | |
| <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"> | |
| Last Active | |
| </th> | |
| <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"> | |
| Actions | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-gray-200"> | |
| {users.length === 0 ? ( | |
| <tr> | |
| <td colSpan={6} className="px-6 py-12 text-center text-gray-500"> | |
| No users found | |
| </td> | |
| </tr> | |
| ) : ( | |
| users.map(user => ( | |
| <tr key={user.id} className="hover:bg-gray-50"> | |
| <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> | |
| {user.username} | |
| </td> | |
| <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> | |
| {new Date(user.createdAt).toLocaleDateString()} | |
| </td> | |
| <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 text-right"> | |
| {user.conversationCount} | |
| </td> | |
| <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 text-right"> | |
| {user.messageCount} | |
| </td> | |
| <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> | |
| {user.lastActiveAt | |
| ? new Date(user.lastActiveAt).toLocaleDateString() | |
| : 'Never'} | |
| </td> | |
| <td className="px-6 py-4 whitespace-nowrap text-right text-sm"> | |
| <Link | |
| href={`/admin/users/${user.id}`} | |
| className="text-blue-600 hover:text-blue-900" | |
| > | |
| View Details | |
| </Link> | |
| </td> | |
| </tr> | |
| )) | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| {/* Users Cards (Mobile) */} | |
| <div className="md:hidden space-y-4"> | |
| {users.length === 0 ? ( | |
| <div className="bg-white rounded-lg shadow p-6 text-center text-gray-500"> | |
| No users found | |
| </div> | |
| ) : ( | |
| users.map(user => ( | |
| <div key={user.id} className="bg-white rounded-lg shadow p-4"> | |
| <div className="flex items-start justify-between mb-3"> | |
| <div> | |
| <h3 className="text-lg font-semibold text-gray-900">{user.username}</h3> | |
| <p className="text-sm text-gray-500"> | |
| Joined {new Date(user.createdAt).toLocaleDateString()} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 gap-4 mb-3"> | |
| <div> | |
| <p className="text-xs text-gray-500">Conversations</p> | |
| <p className="text-lg font-semibold text-gray-900"> | |
| {user.conversationCount} | |
| </p> | |
| </div> | |
| <div> | |
| <p className="text-xs text-gray-500">Messages</p> | |
| <p className="text-lg font-semibold text-gray-900">{user.messageCount}</p> | |
| </div> | |
| </div> | |
| <div className="mb-3"> | |
| <p className="text-xs text-gray-500">Last Active</p> | |
| <p className="text-sm text-gray-700"> | |
| {user.lastActiveAt | |
| ? new Date(user.lastActiveAt).toLocaleDateString() | |
| : 'Never'} | |
| </p> | |
| </div> | |
| <Link | |
| href={`/admin/users/${user.id}`} | |
| className="block w-full text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" | |
| > | |
| View Details | |
| </Link> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| {/* Pagination */} | |
| {totalPages > 1 && ( | |
| <div className="mt-6 flex items-center justify-center gap-2"> | |
| <button | |
| onClick={() => setPage(Math.max(1, page - 1))} | |
| disabled={page === 1} | |
| className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Previous | |
| </button> | |
| <span className="px-4 py-2 text-gray-700"> | |
| Page {page} of {totalPages} | |
| </span> | |
| <button | |
| onClick={() => setPage(Math.min(totalPages, page + 1))} | |
| disabled={page === totalPages} | |
| className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Next | |
| </button> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| ); | |
| } | |