Back to blog

Build a URL Shortener: React Admin Dashboard UI

typescriptreactadminfrontendfullstack
Build a URL Shortener: React Admin Dashboard UI

Over the last three posts, we built a complete admin backend: role-based access control, user management endpoints, URL moderation APIs, system analytics aggregation, and audit logging. You can curl every endpoint, inspect JSON responses, verify that permissions work. But an admin panel nobody can navigate isn't much of a panel.

APIs without a UI are like a car without a steering wheel. The engine runs fine, but good luck driving anywhere. Time to build the admin dashboard frontend — a real interface where admins can monitor system health, manage users, moderate URLs, and review audit trails, all from their browser.

In this post, we'll build the complete admin dashboard as an extension of the existing React frontend from Phase 8. We'll add an admin layout with sidebar navigation, metric cards with trend indicators, interactive charts with Recharts, a full user management table, URL moderation queue with bulk actions, and an audit log viewer with expandable details.

Time commitment: 3-4 hours
Prerequisites: Phase 12: System Analytics & Audit Logs

What we'll build in this post:
✅ Admin layout with sidebar navigation and role-based menu
✅ Dashboard overview page with metric cards and trend indicators
✅ Analytics page with interactive Recharts charts (clicks, URLs, users)
✅ User management page with search, filters, and action buttons
✅ URL moderation queue with bulk actions
✅ Audit log viewer with filtering and detail expansion


Admin Frontend Architecture

Before writing any code, let's map out what we're building. The admin section is a self-contained area within the existing React app, with its own layout, navigation, and pages:

Every admin page follows the same pattern: fetch data with React Query hooks, display in a responsive layout, and handle mutations with optimistic updates. Let's build it piece by piece.


Install Additional Dependencies

We already have React Router, Tailwind CSS, and Recharts from Phase 8. We need a couple more packages for the admin UI:

cd frontend
npm install @tanstack/react-query date-fns
npm install -D @types/react
  • @tanstack/react-query — Data fetching with caching, refetching, and mutation support
  • date-fns — Lightweight date formatting (no moment.js bloat)

Route Setup & Protection

Admin pages need two layers of protection: authentication (user must be logged in) and authorization (user must have admin role). Let's create a route guard component:

// src/components/AdminRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
 
export function AdminRoute({ children }: { children: React.ReactNode }) {
  const { user, isLoading } = useAuth();
 
  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
      </div>
    );
  }
 
  if (!user) {
    return <Navigate to="/login" replace />;
  }
 
  if (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN') {
    return <Navigate to="/dashboard" replace />;
  }
 
  return <>{children}</>;
}

Now wire it into the existing router. Add the admin routes alongside the existing user routes:

// src/App.tsx — add these routes inside your Router
import { AdminRoute } from './components/AdminRoute';
import { AdminLayout } from './layouts/AdminLayout';
import { AdminDashboard } from './pages/admin/AdminDashboard';
import { AdminAnalytics } from './pages/admin/AdminAnalytics';
import { AdminUsers } from './pages/admin/AdminUsers';
import { AdminUrls } from './pages/admin/AdminUrls';
import { AdminAuditLogs } from './pages/admin/AdminAuditLogs';
 
// Inside your route configuration:
<Route
  element={
    <AdminRoute>
      <AdminLayout />
    </AdminRoute>
  }
>
  <Route path="/admin" element={<AdminDashboard />} />
  <Route path="/admin/analytics" element={<AdminAnalytics />} />
  <Route path="/admin/users" element={<AdminUsers />} />
  <Route path="/admin/urls" element={<AdminUrls />} />
  <Route path="/admin/audit-logs" element={<AdminAuditLogs />} />
</Route>

The AdminRoute guard wraps the entire layout, so every nested route is protected. If a regular user tries to visit /admin, they get bounced to their own dashboard.


Admin Layout with Sidebar Navigation

The admin layout is the skeleton every admin page lives inside — a fixed sidebar on the left, content area on the right, with a top bar for mobile. This is the most important component to get right because every page inherits it:

// src/layouts/AdminLayout.tsx
import { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
 
const navItems = [
  {
    path: '/admin',
    label: 'Dashboard',
    icon: '📊',
    end: true, // exact match
  },
  {
    path: '/admin/analytics',
    label: 'Analytics',
    icon: '📈',
  },
  {
    path: '/admin/users',
    label: 'Users',
    icon: '👥',
  },
  {
    path: '/admin/urls',
    label: 'URLs',
    icon: '🔗',
  },
  {
    path: '/admin/audit-logs',
    label: 'Audit Logs',
    icon: '📋',
  },
];
 
export function AdminLayout() {
  const [sidebarOpen, setSidebarOpen] = useState(false);
  const { user, logout } = useAuth();
  const navigate = useNavigate();
 
  const handleLogout = () => {
    logout();
    navigate('/login');
  };
 
  return (
    <div className="flex h-screen bg-gray-100 dark:bg-gray-900">
      {/* Mobile overlay */}
      {sidebarOpen && (
        <div
          className="fixed inset-0 z-20 bg-black/50 lg:hidden"
          onClick={() => setSidebarOpen(false)}
        />
      )}
 
      {/* Sidebar */}
      <aside
        className={`
          fixed inset-y-0 left-0 z-30 w-64 bg-white dark:bg-gray-800
          border-r border-gray-200 dark:border-gray-700
          transform transition-transform duration-200 ease-in-out
          lg:translate-x-0 lg:static lg:inset-auto
          ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
        `}
      >
        {/* Sidebar header */}
        <div className="flex items-center justify-between h-16 px-6 border-b border-gray-200 dark:border-gray-700">
          <NavLink to="/admin" className="flex items-center space-x-2">
            <span className="text-xl font-bold text-blue-600 dark:text-blue-400">
              Admin Panel
            </span>
          </NavLink>
          <button
            onClick={() => setSidebarOpen(false)}
            className="lg:hidden text-gray-500 hover:text-gray-700"
          >

          </button>
        </div>
 
        {/* Navigation */}
        <nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
          {navItems.map((item) => (
            <NavLink
              key={item.path}
              to={item.path}
              end={item.end}
              onClick={() => setSidebarOpen(false)}
              className={({ isActive }) =>
                `flex items-center px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
                  isActive
                    ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
                    : 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
                }`
              }
            >
              <span className="mr-3 text-lg">{item.icon}</span>
              {item.label}
            </NavLink>
          ))}
        </nav>
 
        {/* User info at bottom */}
        <div className="p-4 border-t border-gray-200 dark:border-gray-700">
          <div className="flex items-center space-x-3">
            <div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
              <span className="text-sm font-medium text-blue-600 dark:text-blue-300">
                {user?.name?.charAt(0).toUpperCase()}
              </span>
            </div>
            <div className="flex-1 min-w-0">
              <p className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">
                {user?.name}
              </p>
              <p className="text-xs text-gray-500 dark:text-gray-400 truncate">
                {user?.role}
              </p>
            </div>
          </div>
          <button
            onClick={handleLogout}
            className="mt-3 w-full px-4 py-2 text-sm text-gray-600 dark:text-gray-300
                       hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
          >
            Sign Out
          </button>
        </div>
      </aside>
 
      {/* Main content */}
      <div className="flex-1 flex flex-col overflow-hidden">
        {/* Top bar (mobile) */}
        <header className="lg:hidden flex items-center h-16 px-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
          <button
            onClick={() => setSidebarOpen(true)}
            className="text-gray-500 hover:text-gray-700 dark:text-gray-300"
          >
            <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
            </svg>
          </button>
          <span className="ml-4 text-lg font-semibold text-gray-800 dark:text-gray-200">
            Admin
          </span>
        </header>
 
        {/* Page content */}
        <main className="flex-1 overflow-y-auto p-6">
          <Outlet />
        </main>
      </div>
    </div>
  );
}

Key details in this layout:

  • Mobile-responsive — the sidebar slides in from the left on mobile with an overlay backdrop
  • Active link highlightingNavLink with isActive gives visual feedback on current page
  • User info — shows the logged-in admin's name and role at the bottom
  • Dark mode — every element has dark: variants for seamless theme switching
  • <Outlet /> — React Router renders the matched child page here

Admin API Client & Hooks

Before building pages, we need hooks to fetch admin data. We'll extend the existing API client and create dedicated hooks with React Query:

// src/api/adminApi.ts
import { apiClient } from './client';
 
export interface DashboardMetrics {
  totalUsers: number;
  totalUrls: number;
  totalClicks: number;
  flaggedUrls: number;
  userChange: number;    // percentage change from yesterday
  urlChange: number;
  clickChange: number;
  flaggedChange: number;
  todayUsers: number;
  todayUrls: number;
  todayClicks: number;
  clicksLast7Days: { date: string; count: number }[];
}
 
export interface AdminUser {
  id: string;
  name: string;
  email: string;
  role: 'USER' | 'ADMIN' | 'SUPER_ADMIN';
  status: 'ACTIVE' | 'SUSPENDED';
  urlCount: number;
  totalClicks: number;
  createdAt: string;
  lastLoginAt: string | null;
}
 
export interface AdminUrl {
  id: string;
  shortCode: string;
  originalUrl: string;
  userId: string;
  userEmail: string;
  status: 'ACTIVE' | 'FLAGGED' | 'DISABLED';
  clickCount: number;
  createdAt: string;
  flagReason?: string;
}
 
export interface AuditLog {
  id: string;
  adminId: string;
  adminName: string;
  action: string;
  targetType: string;
  targetId: string;
  details: Record<string, unknown>;
  ipAddress: string;
  createdAt: string;
}
 
export interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}
 
export interface UserQueryParams {
  page?: number;
  pageSize?: number;
  search?: string;
  role?: string;
  status?: string;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}
 
export interface UrlQueryParams {
  page?: number;
  pageSize?: number;
  search?: string;
  status?: string;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}
 
export interface AuditQueryParams {
  page?: number;
  pageSize?: number;
  action?: string;
  adminId?: string;
  startDate?: string;
  endDate?: string;
}
 
export const adminApi = {
  // Dashboard
  getDashboardMetrics: () =>
    apiClient.get<DashboardMetrics>('/admin/dashboard').then((r) => r.data),
 
  // Analytics
  getClicksOverTime: (period: '7d' | '30d' | '90d') =>
    apiClient.get(`/admin/analytics/clicks?period=${period}`).then((r) => r.data),
 
  getUrlCreationTrends: (period: '7d' | '30d' | '90d') =>
    apiClient.get(`/admin/analytics/urls?period=${period}`).then((r) => r.data),
 
  getDeviceBreakdown: () =>
    apiClient.get('/admin/analytics/devices').then((r) => r.data),
 
  getUserGrowth: (period: '30d' | '90d' | '1y') =>
    apiClient.get(`/admin/analytics/user-growth?period=${period}`).then((r) => r.data),
 
  getTopCountries: () =>
    apiClient.get('/admin/analytics/countries').then((r) => r.data),
 
  // Users
  getUsers: (params: UserQueryParams) =>
    apiClient
      .get<PaginatedResponse<AdminUser>>('/admin/users', { params })
      .then((r) => r.data),
 
  suspendUser: (userId: string, reason: string) =>
    apiClient.post(`/admin/users/${userId}/suspend`, { reason }),
 
  reactivateUser: (userId: string) =>
    apiClient.post(`/admin/users/${userId}/reactivate`),
 
  updateUserRole: (userId: string, role: string) =>
    apiClient.patch(`/admin/users/${userId}/role`, { role }),
 
  // URLs
  getUrls: (params: UrlQueryParams) =>
    apiClient
      .get<PaginatedResponse<AdminUrl>>('/admin/urls', { params })
      .then((r) => r.data),
 
  flagUrl: (urlId: string, reason: string) =>
    apiClient.post(`/admin/urls/${urlId}/flag`, { reason }),
 
  disableUrl: (urlId: string) =>
    apiClient.post(`/admin/urls/${urlId}/disable`),
 
  reactivateUrl: (urlId: string) =>
    apiClient.post(`/admin/urls/${urlId}/reactivate`),
 
  bulkDisableUrls: (urlIds: string[]) =>
    apiClient.post('/admin/urls/bulk-disable', { urlIds }),
 
  bulkDeleteUrls: (urlIds: string[]) =>
    apiClient.post('/admin/urls/bulk-delete', { urlIds }),
 
  // Audit Logs
  getAuditLogs: (params: AuditQueryParams) =>
    apiClient
      .get<PaginatedResponse<AuditLog>>('/admin/audit-logs', { params })
      .then((r) => r.data),
};

Now the hooks that use these API calls:

// src/hooks/useAdminApi.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi, UserQueryParams, UrlQueryParams, AuditQueryParams } from '../api/adminApi';
 
// ============================================
// Dashboard
// ============================================
export function useDashboardMetrics() {
  return useQuery({
    queryKey: ['admin', 'dashboard'],
    queryFn: () => adminApi.getDashboardMetrics(),
    refetchInterval: 5 * 60 * 1000, // Auto-refresh every 5 minutes
  });
}
 
// ============================================
// Analytics
// ============================================
export function useClicksOverTime(period: '7d' | '30d' | '90d') {
  return useQuery({
    queryKey: ['admin', 'analytics', 'clicks', period],
    queryFn: () => adminApi.getClicksOverTime(period),
  });
}
 
export function useUrlCreationTrends(period: '7d' | '30d' | '90d') {
  return useQuery({
    queryKey: ['admin', 'analytics', 'urls', period],
    queryFn: () => adminApi.getUrlCreationTrends(period),
  });
}
 
export function useDeviceBreakdown() {
  return useQuery({
    queryKey: ['admin', 'analytics', 'devices'],
    queryFn: () => adminApi.getDeviceBreakdown(),
  });
}
 
export function useUserGrowth(period: '30d' | '90d' | '1y') {
  return useQuery({
    queryKey: ['admin', 'analytics', 'user-growth', period],
    queryFn: () => adminApi.getUserGrowth(period),
  });
}
 
export function useTopCountries() {
  return useQuery({
    queryKey: ['admin', 'analytics', 'countries'],
    queryFn: () => adminApi.getTopCountries(),
  });
}
 
// ============================================
// Users
// ============================================
export function useAdminUsers(params: UserQueryParams) {
  return useQuery({
    queryKey: ['admin', 'users', params],
    queryFn: () => adminApi.getUsers(params),
    placeholderData: (previousData) => previousData,
  });
}
 
export function useSuspendUser() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ userId, reason }: { userId: string; reason: string }) =>
      adminApi.suspendUser(userId, reason),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
      queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
    },
  });
}
 
export function useReactivateUser() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (userId: string) => adminApi.reactivateUser(userId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
    },
  });
}
 
export function useUpdateUserRole() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ userId, role }: { userId: string; role: string }) =>
      adminApi.updateUserRole(userId, role),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
    },
  });
}
 
// ============================================
// URLs
// ============================================
export function useAdminUrls(params: UrlQueryParams) {
  return useQuery({
    queryKey: ['admin', 'urls', params],
    queryFn: () => adminApi.getUrls(params),
    placeholderData: (previousData) => previousData,
  });
}
 
export function useFlagUrl() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ urlId, reason }: { urlId: string; reason: string }) =>
      adminApi.flagUrl(urlId, reason),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'urls'] });
      queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
    },
  });
}
 
export function useDisableUrl() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (urlId: string) => adminApi.disableUrl(urlId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'urls'] });
    },
  });
}
 
export function useBulkDisableUrls() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (urlIds: string[]) => adminApi.bulkDisableUrls(urlIds),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'urls'] });
      queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
    },
  });
}
 
// ============================================
// Audit Logs
// ============================================
export function useAuditLogs(params: AuditQueryParams) {
  return useQuery({
    queryKey: ['admin', 'audit-logs', params],
    queryFn: () => adminApi.getAuditLogs(params),
    placeholderData: (previousData) => previousData,
  });
}

Important patterns to notice:

  • queryKey arrays — React Query uses these to cache and deduplicate requests. Including params in the key means different filters produce different cache entries.
  • invalidateQueries on mutation — after suspending a user, we invalidate both the users list and dashboard metrics so they refresh with updated data.
  • placeholderData — keeps the previous data visible while loading new results (no flash of empty table when changing pages).
  • refetchInterval — the dashboard auto-refreshes every 5 minutes so metrics stay current.

Dashboard Overview Page

The dashboard is the admin's home base — a quick glance at system health. It shows four metric cards with trend indicators, today's quick stats, and a mini sparkline chart of recent clicks:

// src/pages/admin/AdminDashboard.tsx
import { useDashboardMetrics } from '../../hooks/useAdminApi';
 
interface MetricCardProps {
  title: string;
  value: number;
  change: number;
  icon: string;
  color: 'blue' | 'green' | 'purple' | 'red';
}
 
const colorMap = {
  blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
  green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
  purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
  red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
};
 
function MetricCard({ title, value, change, icon, color }: MetricCardProps) {
  const isPositive = change >= 0;
 
  return (
    <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
      <div className="flex items-center justify-between">
        <div>
          <p className="text-sm font-medium text-gray-500 dark:text-gray-400">
            {title}
          </p>
          <p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">
            {value.toLocaleString()}
          </p>
        </div>
        <div className={`w-12 h-12 rounded-lg flex items-center justify-center text-2xl ${colorMap[color]}`}>
          {icon}
        </div>
      </div>
      <div className="mt-4 flex items-center">
        <span
          className={`inline-flex items-center text-sm font-medium ${
            isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
          }`}
        >
          {isPositive ? '↑' : '↓'} {Math.abs(change)}%
        </span>
        <span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
          from yesterday
        </span>
      </div>
    </div>
  );
}
 
function QuickStats({
  todayUsers,
  todayUrls,
  todayClicks,
}: {
  todayUsers: number;
  todayUrls: number;
  todayClicks: number;
}) {
  return (
    <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
      <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
        Today's Activity
      </h3>
      <div className="grid grid-cols-3 gap-4">
        <div className="text-center">
          <p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
            {todayUsers}
          </p>
          <p className="text-sm text-gray-500 dark:text-gray-400">New Users</p>
        </div>
        <div className="text-center">
          <p className="text-2xl font-bold text-green-600 dark:text-green-400">
            {todayUrls}
          </p>
          <p className="text-sm text-gray-500 dark:text-gray-400">URLs Created</p>
        </div>
        <div className="text-center">
          <p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
            {todayClicks}
          </p>
          <p className="text-sm text-gray-500 dark:text-gray-400">Total Clicks</p>
        </div>
      </div>
    </div>
  );
}
 
function MiniClicksChart({ data }: { data: { date: string; count: number }[] }) {
  if (!data || data.length === 0) return null;
  const max = Math.max(...data.map((d) => d.count));
 
  return (
    <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
      <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
        Clicks — Last 7 Days
      </h3>
      <div className="flex items-end space-x-2 h-32">
        {data.map((day) => (
          <div key={day.date} className="flex-1 flex flex-col items-center">
            <div
              className="w-full bg-blue-500 dark:bg-blue-400 rounded-t"
              style={{ height: `${max > 0 ? (day.count / max) * 100 : 0}%` }}
            />
            <span className="text-xs text-gray-400 mt-1">
              {new Date(day.date).toLocaleDateString('en', { weekday: 'short' })}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}
 
export function AdminDashboard() {
  const { data: metrics, isLoading, error } = useDashboardMetrics();
 
  if (isLoading) {
    return <DashboardSkeleton />;
  }
 
  if (error) {
    return (
      <div className="text-center py-12">
        <p className="text-red-600 dark:text-red-400">
          Failed to load dashboard metrics. Please try again.
        </p>
      </div>
    );
  }
 
  if (!metrics) return null;
 
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
          Dashboard
        </h1>
        <p className="text-gray-500 dark:text-gray-400 mt-1">
          System overview and key metrics
        </p>
      </div>
 
      {/* Metric cards */}
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
        <MetricCard
          title="Total Users"
          value={metrics.totalUsers}
          change={metrics.userChange}
          icon="👥"
          color="blue"
        />
        <MetricCard
          title="Total URLs"
          value={metrics.totalUrls}
          change={metrics.urlChange}
          icon="🔗"
          color="green"
        />
        <MetricCard
          title="Total Clicks"
          value={metrics.totalClicks}
          change={metrics.clickChange}
          icon="📈"
          color="purple"
        />
        <MetricCard
          title="Flagged URLs"
          value={metrics.flaggedUrls}
          change={metrics.flaggedChange}
          icon="⚠️"
          color="red"
        />
      </div>
 
      {/* Second row */}
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <QuickStats
          todayUsers={metrics.todayUsers}
          todayUrls={metrics.todayUrls}
          todayClicks={metrics.todayClicks}
        />
        <MiniClicksChart data={metrics.clicksLast7Days} />
      </div>
    </div>
  );
}
 
function DashboardSkeleton() {
  return (
    <div className="space-y-6 animate-pulse">
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
        {[1, 2, 3, 4].map((i) => (
          <div key={i} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 h-32" />
        ))}
      </div>
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 h-48" />
        <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 h-48" />
      </div>
    </div>
  );
}

The DashboardSkeleton shows placeholder shapes while data loads — much better UX than a blank page or a single spinner. Each metric card shows the current value plus a percentage change from yesterday, so admins can spot trends instantly.


Analytics Charts Page

The analytics page goes deeper than the dashboard. Here we use Recharts to build interactive, responsive charts that let admins explore click patterns, URL creation trends, device distributions, and user growth:

// src/pages/admin/AdminAnalytics.tsx
import { useState } from 'react';
import {
  LineChart,
  Line,
  BarChart,
  Bar,
  PieChart,
  Pie,
  Cell,
  AreaChart,
  Area,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from 'recharts';
import {
  useClicksOverTime,
  useUrlCreationTrends,
  useDeviceBreakdown,
  useUserGrowth,
  useTopCountries,
} from '../../hooks/useAdminApi';
 
type TimePeriod = '7d' | '30d' | '90d';
 
function PeriodToggle({
  value,
  onChange,
}: {
  value: TimePeriod;
  onChange: (v: TimePeriod) => void;
}) {
  const options: TimePeriod[] = ['7d', '30d', '90d'];
  return (
    <div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
      {options.map((opt) => (
        <button
          key={opt}
          onClick={() => onChange(opt)}
          className={`px-3 py-1 text-sm rounded-md transition-colors ${
            value === opt
              ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
              : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
          }`}
        >
          {opt}
        </button>
      ))}
    </div>
  );
}
 
function ChartCard({
  title,
  children,
  actions,
}: {
  title: string;
  children: React.ReactNode;
  actions?: React.ReactNode;
}) {
  return (
    <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
      <div className="flex items-center justify-between mb-6">
        <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
          {title}
        </h3>
        {actions}
      </div>
      {children}
    </div>
  );
}
 
const CHART_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
 
function ClicksChart() {
  const [period, setPeriod] = useState<TimePeriod>('7d');
  const { data, isLoading } = useClicksOverTime(period);
 
  return (
    <ChartCard
      title="Clicks Over Time"
      actions={<PeriodToggle value={period} onChange={setPeriod} />}
    >
      {isLoading ? (
        <div className="h-72 flex items-center justify-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
        </div>
      ) : (
        <ResponsiveContainer width="100%" height={300}>
          <LineChart data={data}>
            <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
            <XAxis
              dataKey="date"
              tick={{ fontSize: 12 }}
              tickFormatter={(date: string) =>
                new Date(date).toLocaleDateString('en', { month: 'short', day: 'numeric' })
              }
            />
            <YAxis tick={{ fontSize: 12 }} />
            <Tooltip
              contentStyle={{
                backgroundColor: 'var(--tooltip-bg, #fff)',
                border: '1px solid var(--tooltip-border, #e5e7eb)',
                borderRadius: '8px',
              }}
              labelFormatter={(date: string) =>
                new Date(date).toLocaleDateString('en', {
                  weekday: 'long',
                  month: 'long',
                  day: 'numeric',
                })
              }
            />
            <Legend />
            <Line
              type="monotone"
              dataKey="clicks"
              stroke="#3b82f6"
              strokeWidth={2}
              dot={false}
              activeDot={{ r: 6 }}
              name="Clicks"
            />
          </LineChart>
        </ResponsiveContainer>
      )}
    </ChartCard>
  );
}
 
function UrlCreationChart() {
  const [period, setPeriod] = useState<TimePeriod>('30d');
  const { data, isLoading } = useUrlCreationTrends(period);
 
  return (
    <ChartCard
      title="URL Creation Trends"
      actions={<PeriodToggle value={period} onChange={setPeriod} />}
    >
      {isLoading ? (
        <div className="h-72 flex items-center justify-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
        </div>
      ) : (
        <ResponsiveContainer width="100%" height={300}>
          <BarChart data={data}>
            <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
            <XAxis
              dataKey="date"
              tick={{ fontSize: 12 }}
              tickFormatter={(date: string) =>
                new Date(date).toLocaleDateString('en', { month: 'short', day: 'numeric' })
              }
            />
            <YAxis tick={{ fontSize: 12 }} />
            <Tooltip
              contentStyle={{
                backgroundColor: 'var(--tooltip-bg, #fff)',
                border: '1px solid var(--tooltip-border, #e5e7eb)',
                borderRadius: '8px',
              }}
            />
            <Legend />
            <Bar dataKey="urls" fill="#10b981" radius={[4, 4, 0, 0]} name="URLs Created" />
          </BarChart>
        </ResponsiveContainer>
      )}
    </ChartCard>
  );
}
 
function DeviceBreakdownChart() {
  const { data, isLoading } = useDeviceBreakdown();
 
  return (
    <ChartCard title="Device Breakdown">
      {isLoading ? (
        <div className="h-72 flex items-center justify-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
        </div>
      ) : (
        <ResponsiveContainer width="100%" height={300}>
          <PieChart>
            <Pie
              data={data}
              cx="50%"
              cy="50%"
              innerRadius={60}
              outerRadius={100}
              dataKey="count"
              nameKey="device"
              label={({ name, percent }: { name: string; percent: number }) =>
                `${name} ${(percent * 100).toFixed(0)}%`
              }
            >
              {data?.map((_entry: unknown, index: number) => (
                <Cell key={index} fill={CHART_COLORS[index % CHART_COLORS.length]} />
              ))}
            </Pie>
            <Tooltip />
            <Legend />
          </PieChart>
        </ResponsiveContainer>
      )}
    </ChartCard>
  );
}
 
function UserGrowthChart() {
  const [period, setPeriod] = useState<'30d' | '90d' | '1y'>('90d');
  const { data, isLoading } = useUserGrowth(period);
 
  return (
    <ChartCard
      title="User Growth"
      actions={
        <div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
          {(['30d', '90d', '1y'] as const).map((opt) => (
            <button
              key={opt}
              onClick={() => setPeriod(opt)}
              className={`px-3 py-1 text-sm rounded-md transition-colors ${
                period === opt
                  ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
                  : 'text-gray-500 dark:text-gray-400'
              }`}
            >
              {opt}
            </button>
          ))}
        </div>
      }
    >
      {isLoading ? (
        <div className="h-72 flex items-center justify-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
        </div>
      ) : (
        <ResponsiveContainer width="100%" height={300}>
          <AreaChart data={data}>
            <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
            <XAxis
              dataKey="date"
              tick={{ fontSize: 12 }}
              tickFormatter={(date: string) =>
                new Date(date).toLocaleDateString('en', { month: 'short' })
              }
            />
            <YAxis tick={{ fontSize: 12 }} />
            <Tooltip />
            <Legend />
            <Area
              type="monotone"
              dataKey="totalUsers"
              stroke="#8b5cf6"
              fill="#8b5cf6"
              fillOpacity={0.2}
              strokeWidth={2}
              name="Total Users"
            />
          </AreaChart>
        </ResponsiveContainer>
      )}
    </ChartCard>
  );
}
 
function TopCountriesTable() {
  const { data, isLoading } = useTopCountries();
 
  return (
    <ChartCard title="Top Countries">
      {isLoading ? (
        <div className="space-y-3 animate-pulse">
          {[1, 2, 3, 4, 5].map((i) => (
            <div key={i} className="h-8 bg-gray-200 dark:bg-gray-700 rounded" />
          ))}
        </div>
      ) : (
        <div className="space-y-3">
          {data?.slice(0, 10).map(
            (country: { code: string; name: string; clicks: number }, index: number) => (
              <div key={country.code} className="flex items-center justify-between">
                <div className="flex items-center space-x-3">
                  <span className="text-sm text-gray-400 w-6">{index + 1}.</span>
                  <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
                    {country.name}
                  </span>
                </div>
                <span className="text-sm font-semibold text-gray-900 dark:text-white">
                  {country.clicks.toLocaleString()}
                </span>
              </div>
            )
          )}
        </div>
      )}
    </ChartCard>
  );
}
 
export function AdminAnalytics() {
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
          Analytics
        </h1>
        <p className="text-gray-500 dark:text-gray-400 mt-1">
          Detailed system analytics and trends
        </p>
      </div>
 
      {/* Full-width charts */}
      <ClicksChart />
      <UrlCreationChart />
 
      {/* Two-column layout */}
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <DeviceBreakdownChart />
        <TopCountriesTable />
      </div>
 
      {/* User growth full-width */}
      <UserGrowthChart />
    </div>
  );
}

A few Recharts tips worth noting:

  • ResponsiveContainer wraps every chart so it fills its parent and resizes correctly
  • CartesianGrid with strokeDasharray gives a subtle grid that doesn't overwhelm the data
  • Custom tickFormatter on XAxis converts raw dates to readable labels
  • dot={false} with activeDot on LineChart keeps the line clean but shows a dot on hover
  • Donut chart is a PieChart with innerRadius set — cleaner than a full pie

User Management Page

This is the most interaction-heavy page. Admins need to search users, filter by role and status, sort columns, and take actions like suspend, reactivate, or change roles. Let's build it step by step:

// src/pages/admin/AdminUsers.tsx
import { useState } from 'react';
import { format } from 'date-fns';
import {
  useAdminUsers,
  useSuspendUser,
  useReactivateUser,
  useUpdateUserRole,
} from '../../hooks/useAdminApi';
import { AdminUser } from '../../api/adminApi';
 
interface ConfirmModalProps {
  isOpen: boolean;
  title: string;
  message: string;
  confirmLabel: string;
  confirmVariant?: 'danger' | 'primary';
  onConfirm: () => void;
  onCancel: () => void;
  children?: React.ReactNode;
}
 
function ConfirmModal({
  isOpen,
  title,
  message,
  confirmLabel,
  confirmVariant = 'danger',
  onConfirm,
  onCancel,
  children,
}: ConfirmModalProps) {
  if (!isOpen) return null;
 
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div className="absolute inset-0 bg-black/50" onClick={onCancel} />
      <div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
        <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
          {title}
        </h3>
        <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
          {message}
        </p>
        {children}
        <div className="mt-6 flex justify-end space-x-3">
          <button
            onClick={onCancel}
            className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300
                       bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
          >
            Cancel
          </button>
          <button
            onClick={onConfirm}
            className={`px-4 py-2 text-sm font-medium text-white rounded-lg ${
              confirmVariant === 'danger'
                ? 'bg-red-600 hover:bg-red-700'
                : 'bg-blue-600 hover:bg-blue-700'
            }`}
          >
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>
  );
}
 
function StatusBadge({ status }: { status: string }) {
  const styles =
    status === 'ACTIVE'
      ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
      : 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400';
 
  return (
    <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles}`}>
      {status}
    </span>
  );
}
 
function RoleBadge({ role }: { role: string }) {
  const styles: Record<string, string> = {
    SUPER_ADMIN: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400',
    ADMIN: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
    USER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300',
  };
 
  return (
    <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[role] || styles.USER}`}>
      {role}
    </span>
  );
}
 
export function AdminUsers() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');
  const [roleFilter, setRoleFilter] = useState('');
  const [statusFilter, setStatusFilter] = useState('');
  const [sortBy, setSortBy] = useState('createdAt');
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
 
  // Modal state
  const [suspendTarget, setSuspendTarget] = useState<AdminUser | null>(null);
  const [suspendReason, setSuspendReason] = useState('');
  const [roleChangeTarget, setRoleChangeTarget] = useState<AdminUser | null>(null);
  const [newRole, setNewRole] = useState('');
 
  const { data, isLoading } = useAdminUsers({
    page,
    pageSize: 20,
    search: search || undefined,
    role: roleFilter || undefined,
    status: statusFilter || undefined,
    sortBy,
    sortOrder,
  });
 
  const suspendMutation = useSuspendUser();
  const reactivateMutation = useReactivateUser();
  const roleChangeMutation = useUpdateUserRole();
 
  const handleSort = (column: string) => {
    if (sortBy === column) {
      setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
    } else {
      setSortBy(column);
      setSortOrder('asc');
    }
  };
 
  const handleSuspend = () => {
    if (!suspendTarget) return;
    suspendMutation.mutate(
      { userId: suspendTarget.id, reason: suspendReason },
      {
        onSuccess: () => {
          setSuspendTarget(null);
          setSuspendReason('');
        },
      }
    );
  };
 
  const handleReactivate = (user: AdminUser) => {
    reactivateMutation.mutate(user.id);
  };
 
  const handleRoleChange = () => {
    if (!roleChangeTarget || !newRole) return;
    roleChangeMutation.mutate(
      { userId: roleChangeTarget.id, role: newRole },
      {
        onSuccess: () => {
          setRoleChangeTarget(null);
          setNewRole('');
        },
      }
    );
  };
 
  const SortIcon = ({ column }: { column: string }) => {
    if (sortBy !== column) return <span className="text-gray-300 ml-1">↕</span>;
    return <span className="ml-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>;
  };
 
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
          User Management
        </h1>
        <p className="text-gray-500 dark:text-gray-400 mt-1">
          Manage users, roles, and account status
        </p>
      </div>
 
      {/* Filters */}
      <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
        <div className="flex flex-col sm:flex-row gap-4">
          <div className="flex-1">
            <input
              type="text"
              placeholder="Search by name or email..."
              value={search}
              onChange={(e) => {
                setSearch(e.target.value);
                setPage(1);
              }}
              className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                         bg-white dark:bg-gray-700 text-gray-900 dark:text-white
                         placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
          </div>
          <select
            value={roleFilter}
            onChange={(e) => {
              setRoleFilter(e.target.value);
              setPage(1);
            }}
            className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                       bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
          >
            <option value="">All Roles</option>
            <option value="USER">User</option>
            <option value="ADMIN">Admin</option>
            <option value="SUPER_ADMIN">Super Admin</option>
          </select>
          <select
            value={statusFilter}
            onChange={(e) => {
              setStatusFilter(e.target.value);
              setPage(1);
            }}
            className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                       bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
          >
            <option value="">All Status</option>
            <option value="ACTIVE">Active</option>
            <option value="SUSPENDED">Suspended</option>
          </select>
        </div>
      </div>
 
      {/* Table */}
      <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
        <div className="overflow-x-auto">
          <table className="w-full">
            <thead className="bg-gray-50 dark:bg-gray-700/50">
              <tr>
                <th
                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
                  onClick={() => handleSort('name')}
                >
                  User <SortIcon column="name" />
                </th>
                <th
                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
                  onClick={() => handleSort('role')}
                >
                  Role <SortIcon column="role" />
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Status
                </th>
                <th
                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
                  onClick={() => handleSort('urlCount')}
                >
                  URLs <SortIcon column="urlCount" />
                </th>
                <th
                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-700 dark:hover:text-gray-200"
                  onClick={() => handleSort('createdAt')}
                >
                  Joined <SortIcon column="createdAt" />
                </th>
                <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Actions
                </th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
              {isLoading ? (
                Array.from({ length: 5 }).map((_, i) => (
                  <tr key={i} className="animate-pulse">
                    <td className="px-6 py-4">
                      <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
                    </td>
                    <td className="px-6 py-4">
                      <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16" />
                    </td>
                    <td className="px-6 py-4">
                      <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16" />
                    </td>
                    <td className="px-6 py-4">
                      <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-12" />
                    </td>
                    <td className="px-6 py-4">
                      <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
                    </td>
                    <td className="px-6 py-4">
                      <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20" />
                    </td>
                  </tr>
                ))
              ) : data?.data.length === 0 ? (
                <tr>
                  <td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
                    No users found matching your filters.
                  </td>
                </tr>
              ) : (
                data?.data.map((user) => (
                  <tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
                    <td className="px-6 py-4">
                      <div>
                        <p className="text-sm font-medium text-gray-900 dark:text-white">
                          {user.name}
                        </p>
                        <p className="text-sm text-gray-500 dark:text-gray-400">
                          {user.email}
                        </p>
                      </div>
                    </td>
                    <td className="px-6 py-4">
                      <RoleBadge role={user.role} />
                    </td>
                    <td className="px-6 py-4">
                      <StatusBadge status={user.status} />
                    </td>
                    <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
                      {user.urlCount}
                    </td>
                    <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
                      {format(new Date(user.createdAt), 'MMM d, yyyy')}
                    </td>
                    <td className="px-6 py-4 text-right">
                      <div className="flex justify-end space-x-2">
                        {user.status === 'ACTIVE' ? (
                          <button
                            onClick={() => setSuspendTarget(user)}
                            className="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium"
                          >
                            Suspend
                          </button>
                        ) : (
                          <button
                            onClick={() => handleReactivate(user)}
                            disabled={reactivateMutation.isPending}
                            className="text-sm text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 font-medium"
                          >
                            Reactivate
                          </button>
                        )}
                        <button
                          onClick={() => {
                            setRoleChangeTarget(user);
                            setNewRole(user.role);
                          }}
                          className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
                        >
                          Change Role
                        </button>
                      </div>
                    </td>
                  </tr>
                ))
              )}
            </tbody>
          </table>
        </div>
 
        {/* Pagination */}
        {data && data.totalPages > 1 && (
          <div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <p className="text-sm text-gray-500 dark:text-gray-400">
              Showing {(page - 1) * 20 + 1} to {Math.min(page * 20, data.total)} of{' '}
              {data.total} users
            </p>
            <div className="flex space-x-2">
              <button
                onClick={() => setPage((p) => Math.max(1, p - 1))}
                disabled={page === 1}
                className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
                           disabled:opacity-50 disabled:cursor-not-allowed
                           hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
              >
                Previous
              </button>
              <span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
                {page} / {data.totalPages}
              </span>
              <button
                onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
                disabled={page === data.totalPages}
                className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
                           disabled:opacity-50 disabled:cursor-not-allowed
                           hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
              >
                Next
              </button>
            </div>
          </div>
        )}
      </div>
 
      {/* Suspend Modal */}
      <ConfirmModal
        isOpen={!!suspendTarget}
        title="Suspend User"
        message={`Are you sure you want to suspend ${suspendTarget?.name}? They will lose access to their account.`}
        confirmLabel={suspendMutation.isPending ? 'Suspending...' : 'Suspend User'}
        onConfirm={handleSuspend}
        onCancel={() => {
          setSuspendTarget(null);
          setSuspendReason('');
        }}
      >
        <div className="mt-4">
          <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
            Reason (required)
          </label>
          <textarea
            value={suspendReason}
            onChange={(e) => setSuspendReason(e.target.value)}
            placeholder="Why is this user being suspended?"
            rows={3}
            className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                       bg-white dark:bg-gray-700 text-gray-900 dark:text-white
                       placeholder-gray-400 focus:ring-2 focus:ring-red-500 focus:border-transparent"
          />
        </div>
      </ConfirmModal>
 
      {/* Role Change Modal */}
      <ConfirmModal
        isOpen={!!roleChangeTarget}
        title="Change User Role"
        message={`Change role for ${roleChangeTarget?.name}`}
        confirmLabel={roleChangeMutation.isPending ? 'Updating...' : 'Update Role'}
        confirmVariant="primary"
        onConfirm={handleRoleChange}
        onCancel={() => {
          setRoleChangeTarget(null);
          setNewRole('');
        }}
      >
        <div className="mt-4">
          <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
            New Role
          </label>
          <select
            value={newRole}
            onChange={(e) => setNewRole(e.target.value)}
            className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                       bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
          >
            <option value="USER">User</option>
            <option value="ADMIN">Admin</option>
            <option value="SUPER_ADMIN">Super Admin</option>
          </select>
        </div>
      </ConfirmModal>
    </div>
  );
}

This is a lot of code, but the patterns are standard. Every admin data table follows the same shape:

  1. Filter bar at the top with search input and dropdown selects
  2. Sortable table with clickable column headers
  3. Action buttons per row that open confirmation modals
  4. Pagination at the bottom with page info

The ConfirmModal component is reusable — we pass it children to add custom form fields (like the suspend reason textarea or role select).


URL Moderation Queue

The moderation queue is where admins review and manage all shortened URLs. It includes tabs for filtering by status, bulk selection with checkboxes, and batch operations:

// src/pages/admin/AdminUrls.tsx
import { useState } from 'react';
import { format } from 'date-fns';
import { useAdminUrls, useFlagUrl, useDisableUrl, useBulkDisableUrls } from '../../hooks/useAdminApi';
import { AdminUrl } from '../../api/adminApi';
 
type TabStatus = '' | 'FLAGGED' | 'DISABLED';
 
function UrlStatusBadge({ status }: { status: string }) {
  const styles: Record<string, string> = {
    ACTIVE: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
    FLAGGED: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400',
    DISABLED: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400',
  };
 
  return (
    <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || styles.ACTIVE}`}>
      {status}
    </span>
  );
}
 
function truncateUrl(url: string, maxLength: number = 50): string {
  if (url.length <= maxLength) return url;
  return url.substring(0, maxLength) + '...';
}
 
export function AdminUrls() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');
  const [activeTab, setActiveTab] = useState<TabStatus>('');
  const [sortBy, setSortBy] = useState('createdAt');
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  const [flagTarget, setFlagTarget] = useState<AdminUrl | null>(null);
  const [flagReason, setFlagReason] = useState('');
 
  const { data, isLoading } = useAdminUrls({
    page,
    pageSize: 20,
    search: search || undefined,
    status: activeTab || undefined,
    sortBy,
    sortOrder,
  });
 
  const flagMutation = useFlagUrl();
  const disableMutation = useDisableUrl();
  const bulkDisableMutation = useBulkDisableUrls();
 
  const tabs: { label: string; value: TabStatus; count?: number }[] = [
    { label: 'All URLs', value: '' },
    { label: 'Flagged', value: 'FLAGGED' },
    { label: 'Disabled', value: 'DISABLED' },
  ];
 
  const toggleSelect = (id: string) => {
    const next = new Set(selectedIds);
    if (next.has(id)) {
      next.delete(id);
    } else {
      next.add(id);
    }
    setSelectedIds(next);
  };
 
  const toggleSelectAll = () => {
    if (!data) return;
    if (selectedIds.size === data.data.length) {
      setSelectedIds(new Set());
    } else {
      setSelectedIds(new Set(data.data.map((u) => u.id)));
    }
  };
 
  const handleBulkDisable = () => {
    if (selectedIds.size === 0) return;
    if (!confirm(`Disable ${selectedIds.size} selected URLs?`)) return;
    bulkDisableMutation.mutate(Array.from(selectedIds), {
      onSuccess: () => setSelectedIds(new Set()),
    });
  };
 
  const handleFlag = () => {
    if (!flagTarget) return;
    flagMutation.mutate(
      { urlId: flagTarget.id, reason: flagReason },
      {
        onSuccess: () => {
          setFlagTarget(null);
          setFlagReason('');
        },
      }
    );
  };
 
  const handleSort = (column: string) => {
    if (sortBy === column) {
      setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
    } else {
      setSortBy(column);
      setSortOrder('asc');
    }
  };
 
  const SortIcon = ({ column }: { column: string }) => {
    if (sortBy !== column) return <span className="text-gray-300 ml-1">↕</span>;
    return <span className="ml-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>;
  };
 
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
          URL Moderation
        </h1>
        <p className="text-gray-500 dark:text-gray-400 mt-1">
          Review, flag, and manage shortened URLs
        </p>
      </div>
 
      {/* Tabs */}
      <div className="border-b border-gray-200 dark:border-gray-700">
        <nav className="flex space-x-8">
          {tabs.map((tab) => (
            <button
              key={tab.value}
              onClick={() => {
                setActiveTab(tab.value);
                setPage(1);
                setSelectedIds(new Set());
              }}
              className={`py-3 px-1 border-b-2 text-sm font-medium transition-colors ${
                activeTab === tab.value
                  ? 'border-blue-500 text-blue-600 dark:text-blue-400'
                  : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
              }`}
            >
              {tab.label}
            </button>
          ))}
        </nav>
      </div>
 
      {/* Search and bulk actions */}
      <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
        <input
          type="text"
          placeholder="Search by short code or URL..."
          value={search}
          onChange={(e) => {
            setSearch(e.target.value);
            setPage(1);
          }}
          className="w-full sm:w-96 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                     bg-white dark:bg-gray-700 text-gray-900 dark:text-white
                     placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
        />
        {selectedIds.size > 0 && (
          <div className="flex items-center space-x-3">
            <span className="text-sm text-gray-500 dark:text-gray-400">
              {selectedIds.size} selected
            </span>
            <button
              onClick={handleBulkDisable}
              disabled={bulkDisableMutation.isPending}
              className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg
                         disabled:opacity-50"
            >
              {bulkDisableMutation.isPending ? 'Disabling...' : 'Disable Selected'}
            </button>
            <button
              onClick={() => setSelectedIds(new Set())}
              className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-300
                         bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
            >
              Clear Selection
            </button>
          </div>
        )}
      </div>
 
      {/* Table */}
      <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
        <div className="overflow-x-auto">
          <table className="w-full">
            <thead className="bg-gray-50 dark:bg-gray-700/50">
              <tr>
                <th className="px-6 py-3 text-left">
                  <input
                    type="checkbox"
                    checked={data?.data.length ? selectedIds.size === data.data.length : false}
                    onChange={toggleSelectAll}
                    className="rounded border-gray-300 dark:border-gray-600 text-blue-600
                               focus:ring-blue-500 dark:bg-gray-700"
                  />
                </th>
                <th
                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
                  onClick={() => handleSort('shortCode')}
                >
                  Short Code <SortIcon column="shortCode" />
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Original URL
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Creator
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Status
                </th>
                <th
                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
                  onClick={() => handleSort('clickCount')}
                >
                  Clicks <SortIcon column="clickCount" />
                </th>
                <th
                  className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
                  onClick={() => handleSort('createdAt')}
                >
                  Created <SortIcon column="createdAt" />
                </th>
                <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Actions
                </th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
              {isLoading ? (
                Array.from({ length: 5 }).map((_, i) => (
                  <tr key={i} className="animate-pulse">
                    {Array.from({ length: 8 }).map((_, j) => (
                      <td key={j} className="px-6 py-4">
                        <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20" />
                      </td>
                    ))}
                  </tr>
                ))
              ) : data?.data.length === 0 ? (
                <tr>
                  <td colSpan={8} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
                    No URLs found matching your criteria.
                  </td>
                </tr>
              ) : (
                data?.data.map((url) => (
                  <tr key={url.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
                    <td className="px-6 py-4">
                      <input
                        type="checkbox"
                        checked={selectedIds.has(url.id)}
                        onChange={() => toggleSelect(url.id)}
                        className="rounded border-gray-300 dark:border-gray-600 text-blue-600
                                   focus:ring-blue-500 dark:bg-gray-700"
                      />
                    </td>
                    <td className="px-6 py-4">
                      <code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-blue-600 dark:text-blue-400">
                        {url.shortCode}
                      </code>
                    </td>
                    <td className="px-6 py-4">
                      <a
                        href={url.originalUrl}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="text-sm text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400"
                        title={url.originalUrl}
                      >
                        {truncateUrl(url.originalUrl)}
                      </a>
                    </td>
                    <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
                      {url.userEmail}
                    </td>
                    <td className="px-6 py-4">
                      <UrlStatusBadge status={url.status} />
                    </td>
                    <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
                      {url.clickCount.toLocaleString()}
                    </td>
                    <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
                      {format(new Date(url.createdAt), 'MMM d, yyyy')}
                    </td>
                    <td className="px-6 py-4 text-right">
                      <div className="flex justify-end space-x-2">
                        {url.status === 'ACTIVE' && (
                          <>
                            <button
                              onClick={() => setFlagTarget(url)}
                              className="text-sm text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 font-medium"
                            >
                              Flag
                            </button>
                            <button
                              onClick={() => disableMutation.mutate(url.id)}
                              className="text-sm text-red-600 hover:text-red-800 dark:text-red-400 font-medium"
                            >
                              Disable
                            </button>
                          </>
                        )}
                        {url.status === 'FLAGGED' && (
                          <button
                            onClick={() => disableMutation.mutate(url.id)}
                            className="text-sm text-red-600 hover:text-red-800 dark:text-red-400 font-medium"
                          >
                            Disable
                          </button>
                        )}
                        {url.status === 'DISABLED' && (
                          <button
                            onClick={() => {/* reactivate logic */}}
                            className="text-sm text-green-600 hover:text-green-800 dark:text-green-400 font-medium"
                          >
                            Reactivate
                          </button>
                        )}
                      </div>
                    </td>
                  </tr>
                ))
              )}
            </tbody>
          </table>
        </div>
 
        {/* Pagination */}
        {data && data.totalPages > 1 && (
          <div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <p className="text-sm text-gray-500 dark:text-gray-400">
              Showing {(page - 1) * 20 + 1} to {Math.min(page * 20, data.total)} of{' '}
              {data.total} URLs
            </p>
            <div className="flex space-x-2">
              <button
                onClick={() => setPage((p) => Math.max(1, p - 1))}
                disabled={page === 1}
                className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
                           disabled:opacity-50 disabled:cursor-not-allowed
                           hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
              >
                Previous
              </button>
              <span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
                {page} / {data.totalPages}
              </span>
              <button
                onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
                disabled={page === data.totalPages}
                className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
                           disabled:opacity-50 disabled:cursor-not-allowed
                           hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
              >
                Next
              </button>
            </div>
          </div>
        )}
      </div>
 
      {/* Flag Modal */}
      {flagTarget && (
        <div className="fixed inset-0 z-50 flex items-center justify-center">
          <div className="absolute inset-0 bg-black/50" onClick={() => setFlagTarget(null)} />
          <div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
            <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
              Flag URL
            </h3>
            <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
              Flag <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{flagTarget.shortCode}</code> for review.
            </p>
            <div className="mt-4">
              <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                Reason
              </label>
              <textarea
                value={flagReason}
                onChange={(e) => setFlagReason(e.target.value)}
                placeholder="Why is this URL being flagged?"
                rows={3}
                className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                           bg-white dark:bg-gray-700 text-gray-900 dark:text-white
                           placeholder-gray-400 focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
              />
            </div>
            <div className="mt-6 flex justify-end space-x-3">
              <button
                onClick={() => {
                  setFlagTarget(null);
                  setFlagReason('');
                }}
                className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300
                           bg-gray-100 dark:bg-gray-700 rounded-lg"
              >
                Cancel
              </button>
              <button
                onClick={handleFlag}
                disabled={flagMutation.isPending || !flagReason.trim()}
                className="px-4 py-2 text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700
                           rounded-lg disabled:opacity-50"
              >
                {flagMutation.isPending ? 'Flagging...' : 'Flag URL'}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

The bulk action pattern is straightforward: maintain a Set<string> of selected IDs, check/uncheck with toggleSelect, and send the entire set to the bulk API endpoint. The "select all" checkbox toggles between all-on-page and none.


Audit Log Viewer

The audit log viewer gives admins a timeline of every administrative action taken in the system. Each row shows who did what and when, with expandable details for the full context:

// src/pages/admin/AdminAuditLogs.tsx
import { useState } from 'react';
import { format } from 'date-fns';
import { useAuditLogs } from '../../hooks/useAdminApi';
import { AuditLog } from '../../api/adminApi';
 
function formatAuditAction(log: AuditLog): string {
  const actions: Record<string, string> = {
    'user.suspend': 'suspended user',
    'user.reactivate': 'reactivated user',
    'user.role_change': 'changed role for user',
    'url.flag': 'flagged URL',
    'url.disable': 'disabled URL',
    'url.reactivate': 'reactivated URL',
    'url.bulk_disable': 'bulk-disabled URLs',
    'url.bulk_delete': 'bulk-deleted URLs',
    'blocklist.add': 'blocked domain',
    'blocklist.remove': 'unblocked domain',
    'settings.update': 'updated system settings',
  };
  return actions[log.action] || log.action;
}
 
function ActionBadge({ action }: { action: string }) {
  const category = action.split('.')[0];
  const styles: Record<string, string> = {
    user: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
    url: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
    blocklist: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400',
    settings: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400',
  };
 
  return (
    <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[category] || 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}>
      {action}
    </span>
  );
}
 
function LogDetailsPanel({ details }: { details: Record<string, unknown> }) {
  return (
    <div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mt-2">
      <pre className="text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">
        {JSON.stringify(details, null, 2)}
      </pre>
    </div>
  );
}
 
export function AdminAuditLogs() {
  const [page, setPage] = useState(1);
  const [actionFilter, setActionFilter] = useState('');
  const [startDate, setStartDate] = useState('');
  const [endDate, setEndDate] = useState('');
  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
 
  const { data, isLoading } = useAuditLogs({
    page,
    pageSize: 30,
    action: actionFilter || undefined,
    startDate: startDate || undefined,
    endDate: endDate || undefined,
  });
 
  const toggleExpanded = (id: string) => {
    const next = new Set(expandedIds);
    if (next.has(id)) {
      next.delete(id);
    } else {
      next.add(id);
    }
    setExpandedIds(next);
  };
 
  const actionTypes = [
    { value: '', label: 'All Actions' },
    { value: 'user.suspend', label: 'User Suspended' },
    { value: 'user.reactivate', label: 'User Reactivated' },
    { value: 'user.role_change', label: 'Role Changed' },
    { value: 'url.flag', label: 'URL Flagged' },
    { value: 'url.disable', label: 'URL Disabled' },
    { value: 'url.reactivate', label: 'URL Reactivated' },
    { value: 'url.bulk_disable', label: 'Bulk URL Disable' },
    { value: 'blocklist.add', label: 'Domain Blocked' },
    { value: 'blocklist.remove', label: 'Domain Unblocked' },
  ];
 
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
          Audit Logs
        </h1>
        <p className="text-gray-500 dark:text-gray-400 mt-1">
          Track all administrative actions in the system
        </p>
      </div>
 
      {/* Filters */}
      <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
        <div className="flex flex-col sm:flex-row gap-4">
          <select
            value={actionFilter}
            onChange={(e) => {
              setActionFilter(e.target.value);
              setPage(1);
            }}
            className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                       bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
          >
            {actionTypes.map((type) => (
              <option key={type.value} value={type.value}>
                {type.label}
              </option>
            ))}
          </select>
          <div className="flex items-center space-x-2">
            <input
              type="date"
              value={startDate}
              onChange={(e) => {
                setStartDate(e.target.value);
                setPage(1);
              }}
              className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                         bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
            />
            <span className="text-gray-400">to</span>
            <input
              type="date"
              value={endDate}
              onChange={(e) => {
                setEndDate(e.target.value);
                setPage(1);
              }}
              className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                         bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
            />
          </div>
          {(actionFilter || startDate || endDate) && (
            <button
              onClick={() => {
                setActionFilter('');
                setStartDate('');
                setEndDate('');
                setPage(1);
              }}
              className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300
                         bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
            >
              Clear Filters
            </button>
          )}
        </div>
      </div>
 
      {/* Log entries */}
      <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
        <div className="overflow-x-auto">
          <table className="w-full">
            <thead className="bg-gray-50 dark:bg-gray-700/50">
              <tr>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Timestamp
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Admin
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Action
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Description
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  IP Address
                </th>
                <th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                  Details
                </th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
              {isLoading ? (
                Array.from({ length: 8 }).map((_, i) => (
                  <tr key={i} className="animate-pulse">
                    {Array.from({ length: 6 }).map((_, j) => (
                      <td key={j} className="px-6 py-4">
                        <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
                      </td>
                    ))}
                  </tr>
                ))
              ) : data?.data.length === 0 ? (
                <tr>
                  <td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
                    No audit logs found matching your filters.
                  </td>
                </tr>
              ) : (
                data?.data.map((log) => (
                  <>
                    <tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
                      <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
                        {format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
                      </td>
                      <td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">
                        {log.adminName}
                      </td>
                      <td className="px-6 py-4">
                        <ActionBadge action={log.action} />
                      </td>
                      <td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
                        <span className="font-medium">{log.adminName}</span>{' '}
                        {formatAuditAction(log)}{' '}
                        <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">
                          {log.targetId}
                        </code>
                      </td>
                      <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 font-mono">
                        {log.ipAddress}
                      </td>
                      <td className="px-6 py-4 text-center">
                        <button
                          onClick={() => toggleExpanded(log.id)}
                          className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
                        >
                          {expandedIds.has(log.id) ? 'Hide' : 'Show'}
                        </button>
                      </td>
                    </tr>
                    {expandedIds.has(log.id) && (
                      <tr key={`${log.id}-details`}>
                        <td colSpan={6} className="px-6 py-2">
                          <LogDetailsPanel details={log.details} />
                        </td>
                      </tr>
                    )}
                  </>
                ))
              )}
            </tbody>
          </table>
        </div>
 
        {/* Pagination */}
        {data && data.totalPages > 1 && (
          <div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <p className="text-sm text-gray-500 dark:text-gray-400">
              Showing {(page - 1) * 30 + 1} to {Math.min(page * 30, data.total)} of{' '}
              {data.total} entries
            </p>
            <div className="flex space-x-2">
              <button
                onClick={() => setPage((p) => Math.max(1, p - 1))}
                disabled={page === 1}
                className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
                           disabled:opacity-50 disabled:cursor-not-allowed
                           hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
              >
                Previous
              </button>
              <span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">
                {page} / {data.totalPages}
              </span>
              <button
                onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
                disabled={page === data.totalPages}
                className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
                           disabled:opacity-50 disabled:cursor-not-allowed
                           hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
              >
                Next
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

The expandable rows pattern is simple: maintain a Set<string> of expanded log IDs. When the user clicks "Show", add the ID to the set and render an extra <tr> with the JSON details. The LogDetailsPanel renders the raw details object as pretty-printed JSON — useful for debugging and compliance auditing.


Dark Mode Support

Since we built the user-facing frontend with Tailwind's dark mode in Phase 8, the admin pages inherit the same system automatically. Every component in this post uses dark: variants consistently. Here's the pattern:

// Consistent dark mode pattern used throughout admin components
 
// Backgrounds
// Light: bg-white      Dark: dark:bg-gray-800        (cards)
// Light: bg-gray-50    Dark: dark:bg-gray-700/50      (table headers)
// Light: bg-gray-100   Dark: dark:bg-gray-900         (page background)
 
// Text
// Light: text-gray-900 Dark: dark:text-white          (headings)
// Light: text-gray-700 Dark: dark:text-gray-300        (body text)
// Light: text-gray-500 Dark: dark:text-gray-400        (secondary text)
 
// Borders
// Light: border-gray-200 Dark: dark:border-gray-700   (dividers)
 
// Interactive
// Light: hover:bg-gray-50 Dark: dark:hover:bg-gray-700/30  (table rows)

There's nothing extra to configure. The existing ThemeProvider and theme toggle from Phase 8 work for admin pages too. Just be consistent with the dark variants on every element and your admin UI looks great in both modes.

One tip: always test your admin components in dark mode during development. It's easy to miss a dark: variant on a border or background, leaving a jarring white element in an otherwise dark interface.


Common Pitfalls

1. Not Handling Loading and Error States

Every admin page fetches data asynchronously. If you skip loading states, users see a flash of empty content. If you skip error states, users stare at a blank page with no idea what went wrong.

// Bad — no loading or error handling
function AdminPage() {
  const { data } = useAdminUsers({ page: 1 });
  return <UserTable users={data.data} />;  // crashes if data is undefined
}
 
// Good — handle all states
function AdminPage() {
  const { data, isLoading, error } = useAdminUsers({ page: 1 });
 
  if (isLoading) return <TableSkeleton />;
  if (error) return <ErrorMessage message="Failed to load users" />;
  if (!data || data.data.length === 0) return <EmptyState />;
 
  return <UserTable users={data.data} />;
}

2. Forgetting to Invalidate React Query Cache After Mutations

After suspending a user or disabling a URL, the list should reflect the change immediately. If you forget invalidateQueries, the stale data stays visible until the user manually refreshes.

// Bad — mutation succeeds but UI shows old data
const mutation = useMutation({
  mutationFn: (userId: string) => adminApi.suspendUser(userId, 'spam'),
});
 
// Good — invalidate related queries after mutation
const mutation = useMutation({
  mutationFn: (userId: string) => adminApi.suspendUser(userId, 'spam'),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
    queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
  },
});

3. Missing Route Guard on Admin Pages

If you add admin routes but forget to wrap them with AdminRoute, any authenticated user can access admin pages. Always wrap the entire admin layout:

// Bad — anyone can visit /admin
<Route path="/admin" element={<AdminDashboard />} />
 
// Good — only admins get through
<Route element={<AdminRoute><AdminLayout /></AdminRoute>}>
  <Route path="/admin" element={<AdminDashboard />} />
</Route>

4. Not Confirming Destructive Actions

Suspending a user or deleting URLs are serious actions. Never execute them on a single click. Always show a confirmation modal with a clear description of the consequences:

// Bad — instant destructive action
<button onClick={() => suspendUser(user.id)}>Suspend</button>
 
// Good — confirmation with reason
<button onClick={() => setSuspendTarget(user)}>Suspend</button>
<ConfirmModal
  isOpen={!!suspendTarget}
  title="Suspend User"
  message={`This will revoke ${suspendTarget?.name}'s access immediately.`}
  onConfirm={handleSuspendWithReason}
  onCancel={() => setSuspendTarget(null)}
/>

What's Next

We now have a complete admin dashboard: metric cards showing system health at a glance, interactive charts for deep analytics, user management with full CRUD operations, URL moderation with bulk actions, and an audit log viewer for compliance tracking.

In Phase 14: SEO & Link Previews, we'll add Open Graph metadata fetching for shortened URLs, custom link preview pages, sitemap generation, and structured data markup — making our shortened URLs look professional when shared on social media and messaging apps.


Series: Build a URL Shortener
Previous: Phase 12: System Analytics & Audit Logs
Next: Phase 14: SEO & Link Previews

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.