feat: add admin frontend in /admin-frontend

This commit is contained in:
MWorld Deployer 2026-07-15 12:35:48 +05:30
parent aa2c3683d7
commit 9aac2ee90a
26 changed files with 4891 additions and 0 deletions

4
admin-frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.wrangler
.env

13
admin-frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Instagram</title>
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="d9wl4z"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3660
admin-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
{
"name": "admin-instagram-d9wl4z",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"clsx": "^2.0.0",
"tailwind-merge": "^1.14.0",
"class-variance-authority": "^0.7.0",
"tailwindcss-animate": "^1.0.7",
"react-hook-form": "^7.48.2",
"@hookform/resolvers": "^3.3.2",
"zod": "^3.22.4",
"@radix-ui/react-slot": "^1.0.2",
"sonner": "latest",
"axios": "^1.6.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-table": "^8.20.5",
"framer-motion": "^10.16.4",
"recharts": "^2.13.0",
"lucide-react": "^0.378.0",
"react-router-dom": "^6.18.0",
"date-fns": "^3.0.0",
"zustand": "^4.5.7",
"@cashfreepayments/cashfree-js": "^1.0.6"
},
"devDependencies": {
"vite": "^5.4.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^3.4.0",
"@tailwindcss/typography": "^0.5.10",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"typescript": "^5.2.2",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15"
}
}

View File

@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

View File

@ -0,0 +1,42 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { Toaster } from 'sonner';
import Layout from './components/Layout';
import DashboardPage from './pages/DashboardPage';
import UserManagementPage from './pages/UserManagementPage';
import PostManagementPage from './pages/PostManagementPage';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
function App() {
// NOTE: This Admin Panel assumes auth is handled by a query parameter `agentic_token`
// which is read by the api client in `src/api.ts`.
// No login form or auth logic is implemented here as per requirements.
return (
<QueryClientProvider client={queryClient}>
<Router>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users" element={<UserManagementPage />} />
<Route path="/posts" element={<PostManagementPage />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Router>
<Toaster position="bottom-right" theme="dark" />
</QueryClientProvider>
);
}
export default App;

43
admin-frontend/src/api.ts Normal file
View File

@ -0,0 +1,43 @@
import axios from 'axios';
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? "http://localhost:8787"
: "https://app-d9wl4z.laflabs-tech.workers.dev";
/**
* The admin panel is protected by an auth_token in the URL query string.
* We read it once on load and store in sessionStorage so API requests can use it.
* This token is verified against a stored hash in the backend DB's settings table.
*/
function getAdminAuthToken(): string | null {
// Try URL first (initial load), then fall back to sessionStorage (subsequent navigations)
const urlToken = new URLSearchParams(window.location.search).get('auth_token');
if (urlToken) {
sessionStorage.setItem('admin_auth_token', urlToken);
return urlToken;
}
return sessionStorage.getItem('admin_auth_token');
}
export const api = axios.create({
baseURL: API_URL,
headers: { 'Content-Type': 'application/json' }
});
api.interceptors.request.use((config) => {
const token = getAdminAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers['x-mworld-origin'] = window.location.origin;
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
// Do NOT clear token on 401 in admin panel — the token is URL-based and valid for the session
return Promise.reject(error);
}
);

View File

@ -0,0 +1,133 @@
import { useState } from 'react';
import { flexRender, getCoreRowModel, useReactTable, ColumnDef, Table as TanstackTable } from '@tanstack/react-table';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { useDebounce } from '../hooks/useDebounce';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
isLoading: boolean;
pageCount: number;
pagination: { pageIndex: number; pageSize: number };
setPagination: (pagination: { pageIndex: number; pageSize: number }) => void;
setSearch: (search: string) => void;
}
export function DataTable<TData, TValue>({
columns,
data,
isLoading,
pageCount,
pagination,
setPagination,
setSearch,
}: DataTableProps<TData, TValue>) {
const [searchValue, setSearchValue] = useState('');
const debouncedSearch = useDebounce(searchValue, 500);
useState(() => {
setSearch(debouncedSearch);
}, [debouncedSearch, setSearch]);
const table = useReactTable({
data,
columns,
pageCount,
state: {
pagination,
},
onPaginationChange: (updater) => {
if (typeof updater === 'function') {
const newPagination = updater(pagination);
setPagination(newPagination);
} else {
setPagination(updater);
}
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
});
return (
<div className="w-full space-y-4">
<div className="flex items-center py-4">
<Input
placeholder="Filter by email, username, or caption..."
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
className="max-w-sm bg-neutral-800 border-neutral-700 placeholder:text-neutral-500"
/>
</div>
<div className="rounded-md border border-neutral-800">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-neutral-800">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} className="text-neutral-400">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<div className="flex justify-center items-center">
<Loader2 className="h-8 w-8 animate-spin text-acid-green" />
</div>
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'} className="border-neutral-800 hover:bg-neutral-900/50">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-neutral-500">
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-neutral-500">
Page {pagination.pageIndex + 1} of {pageCount}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
const Layout: React.FC = () => {
return (
<div className="min-h-screen bg-black text-neutral-100 font-sans flex">
<Sidebar />
<main className="flex-1 p-4 sm:p-6 lg:p-8 ml-16 md:ml-64">
{/* Add a background texture for aesthetic effect */}
<div className="absolute inset-0 z-0 opacity-[0.03] bg-[url('data:image/svg+xml,%3Csvg%20width=%2240%22%20height=%2240%22%20viewBox=%220%200%2040%2040%22%20xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cg%20fill=%22%23ffffff%22%20fill-opacity=%221%22%20fill-rule=%22evenodd%22%3E%3Cpath%20d=%22M0%2040L40%200H20L0%2020M40%2040V20L20%2040%22/%3E%3C/g%3E%3C/svg%3E')]"></div>
<div className="relative z-10">
<Outlet />
</div>
</main>
</div>
);
};
export default Layout;

View File

@ -0,0 +1,42 @@
import { NavLink } from 'react-router-dom';
import { LayoutGrid, Users, FileText, Hash } from 'lucide-react';
import { cn } from '../lib/utils';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutGrid },
{ name: 'Users', href: '/users', icon: Users },
{ name: 'Posts', href: '/posts', icon: FileText },
];
const Sidebar = () => {
return (
<aside className="fixed top-0 left-0 h-full w-16 md:w-64 bg-neutral-950 border-r border-neutral-800 flex flex-col z-20">
<div className="flex items-center justify-center md:justify-start md:px-6 h-20 border-b border-neutral-800">
<Hash className="h-8 w-8 text-acid-green" />
<h1 className="hidden md:block ml-3 text-xl font-bold tracking-tighter">ADMIN</h1>
</div>
<nav className="flex-1 px-2 md:px-4 py-6 space-y-2">
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
cn(
'group flex items-center p-3 md:p-2 rounded-md text-sm font-medium transition-colors duration-200',
isActive
? 'bg-acid-green text-black'
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-100',
'justify-center md:justify-start'
)
}
>
<item.icon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
<span className="hidden md:block ml-3">{item.name}</span>
</NavLink>
))}
</nav>
</aside>
);
};
export default Sidebar;

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-700 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-acid-green text-black hover:bg-acid-green/90',
destructive:
'bg-red-600 text-neutral-50 hover:bg-red-600/90',
outline:
'border border-neutral-700 bg-transparent hover:bg-neutral-800 hover:text-neutral-50',
secondary:
'bg-neutral-800 text-neutral-50 hover:bg-neutral-700',
ghost:
'hover:bg-neutral-800 hover:text-neutral-50',
link:
'text-neutral-50 underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,52 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border border-neutral-800 bg-neutral-950 text-neutral-50 shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-neutral-400', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-neutral-700 bg-transparent px-3 py-2 text-sm ring-offset-black file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,110 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-neutral-800 font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-neutral-900 data-[state=selected]:bg-neutral-800',
className
)}
{...props}
/>
)
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-neutral-400 [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
)
);
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
)
);
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-neutral-400', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings: "rlig" 1, "calt" 1;
@apply antialiased;
}
}

View File

@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,79 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
const deny = (msg: string) => {
document.body.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;background:#f8fafc;color:#0f172a;">${msg}</div>`;
};
async function boot() {
const urlParams = new URLSearchParams(window.location.search);
const agenticToken = urlParams.get('agentic_token');
const legacyToken = urlParams.get('auth_token');
const activeToken = agenticToken || legacyToken;
if (!activeToken) {
// Check sessionStorage in case the user navigated away and back
const storedToken = sessionStorage.getItem('admin_auth_token');
if (!storedToken) {
deny('Invalid or missing admin access token.');
return;
}
// Token is in sessionStorage and was already validated - mount app directly
const root = document.getElementById('root');
if (!root) { deny('Unable to load admin app.'); return; }
createRoot(root).render(<React.StrictMode><App /></React.StrictMode>);
return;
}
if (agenticToken) {
try {
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
headers: { 'mAuthAgenticToken': agenticToken }
});
const verifyData = await verifyRes.json();
if (!verifyData.success) {
deny(`Unauthorized: ${verifyData.msg || 'Invalid agentic token'}`);
return;
}
localStorage.setItem('auth_token', agenticToken);
} catch (err) {
deny('Authentication service unavailable.');
return;
}
} else if (legacyToken) {
// Legacy hash-based validation
const data = new TextEncoder().encode(legacyToken);
const digest = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(digest))
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
if (hash !== 'a3fe085c7881857775b34eba615865d844c2b732045f3851db78bf8b79365979') {
deny('Invalid or missing admin access token.');
return;
}
localStorage.setItem('auth_token', legacyToken);
}
// Persist token in sessionStorage so API calls work after React Router navigations
sessionStorage.setItem('admin_auth_token', activeToken);
const root = document.getElementById('root');
if (!root) {
deny('Unable to load admin app.');
return;
}
createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
boot().catch(() => {
deny('Unable to validate admin access token.');
});

View File

@ -0,0 +1,92 @@
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { api } from '../api';
import { DashboardStats } from '../types';
import { Users, FileText, Heart, MessageSquare, Loader2 } from 'lucide-react';
const fetchDashboardStats = async () => {
const { data } = await api.get<{ stats: DashboardStats }>('/api/admin/dashboard');
return data.stats;
};
const StatCard = ({ title, value, icon: Icon, colorClass }: { title: string; value: number | string; icon: React.ElementType, colorClass: string }) => (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-400">{title}</CardTitle>
<Icon className={`h-5 w-5 ${colorClass}`} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono">{value}</div>
</CardContent>
</Card>
);
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: { y: 0, opacity: 1 },
};
const DashboardPage = () => {
const { data: stats, isLoading, error } = useQuery<DashboardStats, Error>({
queryKey: ['dashboardStats'],
queryKey: ['dashboardStats'],
queryFn: fetchDashboardStats
});
if (isLoading) {
return (
<div className="flex h-[80vh] items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-acid-green" />
</div>
);
}
if (error) {
return <div className="text-red-500">Error fetching stats: {error.message}</div>;
}
const statItems = [
{ title: 'Total Users', value: stats?.total_users ?? 0, icon: Users, colorClass: 'text-blue-400' },
{ title: 'Total Posts', value: stats?.total_posts ?? 0, icon: FileText, colorClass: 'text-yellow-400' },
{ title: 'Total Likes', value: stats?.total_likes ?? 0, icon: Heart, colorClass: 'text-red-400' },
{ title: 'Total Comments', value: stats?.total_comments ?? 0, icon: MessageSquare, colorClass: 'text-green-400' },
]
return (
<div className="space-y-6">
<motion.h1
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-3xl font-bold tracking-tight text-neutral-100"
>
Dashboard
</motion.h1>
<motion.div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{statItems.map((item) => (
<motion.div key={item.title} variants={itemVariants}>
<StatCard {...item} />
</motion.div>
))}
</motion.div>
</div>
);
};
export default DashboardPage;

View File

@ -0,0 +1,120 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import { api, API_URL } from '../api';
import { Post } from '../types';
import { DataTable } from '../components/DataTable';
import { Button } from '../components/ui/button';
import { Trash2 } from 'lucide-react';
interface PostsResponse {
posts: Post[];
total: number;
}
const fetchPosts = async (page: number, pageSize: number, search: string): Promise<PostsResponse> => {
const { data } = await api.get('/api/admin/posts', {
params: { page: page + 1, pageSize, search },
});
return data;
};
const PostManagementPage = () => {
const queryClient = useQueryClient();
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [search, setSearch] = useState('');
const { data, isLoading } = useQuery<PostsResponse, Error>(
['posts', pagination, search],
() => fetchPosts(pagination.pageIndex, pagination.pageSize, search),
{ keepPreviousData: true }
);
const deletePostMutation = useMutation(
(postId: string) => api.delete(`/api/admin/posts/${postId}`),
{
onSuccess: () => {
toast.success('Post deleted successfully');
queryClient.invalidateQueries(['posts']);
queryClient.invalidateQueries(['dashboardStats']);
},
onError: (err: any) => {
toast.error(err.response?.data?.msg || 'Failed to delete post');
},
}
);
const handleDelete = (postId: string) => {
if (window.confirm('Are you sure you want to delete this post? This action cannot be undone.')) {
deletePostMutation.mutate(postId);
}
};
const columns = useMemo<ColumnDef<Post>[]>(() => [
{
accessorKey: 'media_url',
header: 'Media',
cell: ({ row }) => {
const mediaUrl = row.original.media_url;
const fullUrl = mediaUrl?.startsWith('/api/') ? `${API_URL}${mediaUrl}` : mediaUrl;
if(!fullUrl) return <div className="h-10 w-10 bg-neutral-800 rounded-md"></div>;
return <img src={fullUrl} alt="Post media" className="h-10 w-10 rounded-md object-cover" />
},
},
{
accessorKey: 'caption',
header: 'Caption',
cell: ({ row }) => <p className="max-w-xs truncate">{row.original.caption}</p>
},
{
accessorKey: 'author_username',
header: 'Author',
},
{
accessorKey: 'like_count',
header: 'Likes',
},
{
accessorKey: 'comment_count',
header: 'Comments',
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => (
<span>{formatDistanceToNow(new Date(row.original.created_at), { addSuffix: true })}</span>
),
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<Button variant="destructive" size="icon" onClick={() => handleDelete(row.original.id)} disabled={deletePostMutation.isLoading}>
<Trash2 className="h-4 w-4" />
</Button>
),
}
], [deletePostMutation.isLoading]);
const pageCount = data ? Math.ceil(data.total / pagination.pageSize) : 0;
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight text-neutral-100">Post Management</h1>
<DataTable
columns={columns}
data={data?.posts ?? []}
isLoading={isLoading}
pageCount={pageCount}
pagination={pagination}
setPagination={setPagination}
setSearch={setSearch}
/>
</div>
);
};
export default PostManagementPage;

View File

@ -0,0 +1,123 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import { api, API_URL } from '../api';
import { User } from '../types';
import { DataTable } from '../components/DataTable';
import { Button } from '../components/ui/button';
import { Trash2, User as UserIcon } from 'lucide-react';
interface UsersResponse {
users: User[];
total: number;
}
const fetchUsers = async (page: number, pageSize: number, search: string): Promise<UsersResponse> => {
const { data } = await api.get('/api/admin/users', {
params: { page: page + 1, pageSize, search },
});
return data;
};
const UserManagementPage = () => {
const queryClient = useQueryClient();
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [search, setSearch] = useState('');
const { data, isLoading } = useQuery<UsersResponse, Error>(
['users', pagination, search],
() => fetchUsers(pagination.pageIndex, pagination.pageSize, search),
{ keepPreviousData: true }
);
const deleteUserMutation = useMutation(
// The API expects the user's email as the ID for deletion
(userEmail: string) => api.delete(`/api/admin/users/${userEmail}`),
{
onSuccess: () => {
toast.success('User and all their data deleted successfully');
queryClient.invalidateQueries(['users']);
queryClient.invalidateQueries(['dashboardStats']);
},
onError: (err: any) => {
toast.error(err.response?.data?.msg || 'Failed to delete user');
},
}
);
const handleDelete = (userEmail: string) => {
if (window.confirm('Are you sure you want to delete this user? This will also delete all their posts, likes, and comments. This action cannot be undone.')) {
deleteUserMutation.mutate(userEmail);
}
};
const columns = useMemo<ColumnDef<User>[]>(() => [
{
accessorKey: 'profile_picture_url',
header: '',
cell: ({ row }) => {
const avatarUrl = row.original.profile_picture_url;
const fullUrl = avatarUrl?.startsWith('/api/') ? `${API_URL}${avatarUrl}` : avatarUrl;
return (
<div className="w-10 h-10 rounded-full bg-neutral-800 flex items-center justify-center">
{fullUrl ?
<img src={fullUrl} alt={row.original.username} className="w-full h-full rounded-full object-cover" /> :
<UserIcon className="w-5 h-5 text-neutral-500" />
}
</div>
)
},
size: 40,
},
{
accessorKey: 'username',
header: 'Username',
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'post_count',
header: 'Posts',
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => (
<span>{formatDistanceToNow(new Date(row.original.created_at), { addSuffix: true })}</span>
),
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<Button variant="destructive" size="icon" onClick={() => handleDelete(row.original.email)} disabled={deleteUserMutation.isLoading}>
<Trash2 className="h-4 w-4" />
</Button>
),
}
], [deleteUserMutation.isLoading]);
const pageCount = data ? Math.ceil(data.total / pagination.pageSize) : 0;
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight text-neutral-100">User Management</h1>
<DataTable
columns={columns}
data={data?.users ?? []}
isLoading={isLoading}
pageCount={pageCount}
pagination={pagination}
setPagination={setPagination}
setSearch={setSearch}
/>
</div>
);
};
export default UserManagementPage;

View File

@ -0,0 +1,3 @@
export { default as DashboardPage } from './DashboardPage';
export { default as PostManagementPage } from './PostManagementPage';
export { default as UserManagementPage } from './UserManagementPage';

View File

@ -0,0 +1,28 @@
export interface DashboardStats {
total_users: number;
total_posts: number;
total_likes: number;
total_comments: number;
}
export interface User {
id: string;
email: string;
username: string;
profile_picture_url: string | null;
bio: string | null;
post_count: number;
created_at: string;
}
export interface Post {
id: string;
user_email: string;
media_url: string;
media_type: 'image' | 'video';
caption: string;
created_at: string;
author_username: string;
like_count: number;
comment_count: number;
}

View File

@ -0,0 +1,81 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
'acid-green': '#39FF14',
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-sans)", 'system-ui', 'sans-serif'],
mono: ["var(--font-mono)", 'monospace'],
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": false,
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"react-query": "@tanstack/react-query",
},
},
build: {
rollupOptions: {
onwarn(warning, warn) {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {
return;
}
warn(warning);
},
},
},
})