feat: add admin frontend in /admin-frontend
This commit is contained in:
parent
aa2c3683d7
commit
9aac2ee90a
4
admin-frontend/.gitignore
vendored
Normal file
4
admin-frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.wrangler
|
||||||
|
.env
|
||||||
13
admin-frontend/index.html
Normal file
13
admin-frontend/index.html
Normal 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
3660
admin-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
admin-frontend/package.json
Normal file
44
admin-frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
admin-frontend/postcss.config.js
Normal file
1
admin-frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||||
42
admin-frontend/src/App.tsx
Normal file
42
admin-frontend/src/App.tsx
Normal 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
43
admin-frontend/src/api.ts
Normal 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
133
admin-frontend/src/components/DataTable.tsx
Normal file
133
admin-frontend/src/components/DataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
admin-frontend/src/components/Layout.tsx
Normal file
20
admin-frontend/src/components/Layout.tsx
Normal 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;
|
||||||
42
admin-frontend/src/components/Sidebar.tsx
Normal file
42
admin-frontend/src/components/Sidebar.tsx
Normal 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;
|
||||||
59
admin-frontend/src/components/ui/button.tsx
Normal file
59
admin-frontend/src/components/ui/button.tsx
Normal 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 };
|
||||||
52
admin-frontend/src/components/ui/card.tsx
Normal file
52
admin-frontend/src/components/ui/card.tsx
Normal 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 };
|
||||||
24
admin-frontend/src/components/ui/input.tsx
Normal file
24
admin-frontend/src/components/ui/input.tsx
Normal 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 };
|
||||||
110
admin-frontend/src/components/ui/table.tsx
Normal file
110
admin-frontend/src/components/ui/table.tsx
Normal 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,
|
||||||
|
};
|
||||||
17
admin-frontend/src/hooks/useDebounce.ts
Normal file
17
admin-frontend/src/hooks/useDebounce.ts
Normal 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;
|
||||||
|
}
|
||||||
42
admin-frontend/src/index.css
Normal file
42
admin-frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
admin-frontend/src/lib/utils.ts
Normal file
7
admin-frontend/src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
79
admin-frontend/src/main.tsx
Normal file
79
admin-frontend/src/main.tsx
Normal 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.');
|
||||||
|
});
|
||||||
92
admin-frontend/src/pages/DashboardPage.tsx
Normal file
92
admin-frontend/src/pages/DashboardPage.tsx
Normal 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;
|
||||||
120
admin-frontend/src/pages/PostManagementPage.tsx
Normal file
120
admin-frontend/src/pages/PostManagementPage.tsx
Normal 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;
|
||||||
123
admin-frontend/src/pages/UserManagementPage.tsx
Normal file
123
admin-frontend/src/pages/UserManagementPage.tsx
Normal 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;
|
||||||
3
admin-frontend/src/pages/index.ts
Normal file
3
admin-frontend/src/pages/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default as DashboardPage } from './DashboardPage';
|
||||||
|
export { default as PostManagementPage } from './PostManagementPage';
|
||||||
|
export { default as UserManagementPage } from './UserManagementPage';
|
||||||
28
admin-frontend/src/types/index.ts
Normal file
28
admin-frontend/src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
81
admin-frontend/tailwind.config.js
Normal file
81
admin-frontend/tailwind.config.js
Normal 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")],
|
||||||
|
}
|
||||||
25
admin-frontend/tsconfig.json
Normal file
25
admin-frontend/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
27
admin-frontend/vite.config.ts
Normal file
27
admin-frontend/vite.config.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user