feat: add admin frontend in /admin-frontend

This commit is contained in:
MWorld Deployer 2026-07-15 20:35:25 +05:30
parent 384eb31752
commit 1112e6f062
24 changed files with 4785 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>Dissolve</title>
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="xd1uom"></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-dissolve-xd1uom",
"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,37 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from 'sonner';
import Layout from './components/Layout';
import { DashboardPage, ReportsQueuePage, ReportDetailPage, UserManagementPage } from './pages';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<DashboardPage />} />
<Route path="reports" element={<ReportsQueuePage />} />
<Route path="reports/:reportId" element={<ReportDetailPage />} />
<Route path="users" element={<UserManagementPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</Router>
<Toaster
position="bottom-right"
toastOptions={{
classNames: {
toast: 'bg-neutral-800 border border-neutral-700 text-white rounded-md',
success: '!bg-green-accent/10 !text-green-accent border-green-accent/20',
error: '!bg-red-500/10 !text-red-500 border-red-500/20',
},
}}
/>
</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-xd1uom.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,26 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import { motion } from 'framer-motion';
const Layout = () => {
return (
<div className="min-h-screen bg-black text-neutral-200 font-sans flex antialiased relative">
<div className="fixed inset-0 bg-noise opacity-10 pointer-events-none"></div>
<Sidebar />
<main className="flex-1 pl-64">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="p-8"
>
<Outlet />
</motion.div>
</main>
</div>
);
};
export default Layout;

View File

@ -0,0 +1,55 @@
import { NavLink, useLocation } from 'react-router-dom';
import { cn } from '../lib/utils';
import { Home, List, Users, Triangle } from 'lucide-react';
import { useUserStore } from '../stores/userStore';
const navigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{ name: 'Reports', href: '/reports', icon: List },
{ name: 'Users', href: '/users', icon: Users, adminOnly: true },
];
const Sidebar = () => {
const { user } = useUserStore();
const location = useLocation();
return (
<aside className="fixed top-0 left-0 h-full w-64 bg-neutral-950/50 backdrop-blur-sm border-r border-neutral-800 flex flex-col">
<div className="flex items-center justify-center h-20 border-b border-neutral-800">
<Triangle className="h-6 w-6 text-green-accent" />
<h1 className="text-xl font-bold ml-3 font-mono tracking-wider">DISSOLVE</h1>
</div>
<nav className="flex-1 px-4 py-6">
<ul>
{navigation.map((item) =>
(!item.adminOnly || user?.role === 'admin') && (
<li key={item.name} className="mb-2">
<NavLink
to={item.href}
end={item.href === '/'}
className={({ isActive }) =>
cn(
'flex items-center px-4 py-3 text-sm font-medium rounded-md transition-colors duration-200',
isActive
? 'bg-green-accent/10 text-green-accent'
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-100'
)
}
>
<item.icon className="h-5 w-5 mr-4" />
<span>{item.name}</span>
</NavLink>
</li>
)
)}
</ul>
</nav>
<div className="p-4 border-t border-neutral-800">
<p className="text-xs text-neutral-500">Logged in as</p>
<p className="text-sm font-medium text-neutral-300">{user?.email}</p>
</div>
</aside>
);
};
export default Sidebar;

View File

@ -0,0 +1,49 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-green-accent text-black hover:bg-green-accent/80 focus-visible:ring-green-accent',
destructive:
'bg-red-500 text-neutral-50 hover:bg-red-500/90 focus-visible:ring-red-500',
outline:
'border border-neutral-700 bg-transparent hover:bg-neutral-800 hover:text-neutral-100 focus-visible:ring-neutral-500',
ghost:
'hover:bg-neutral-800 hover:text-neutral-100',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,72 @@
import * as React from 'react';
import { X } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { Button } from './button';
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
children?: React.ReactNode;
onConfirm?: () => void;
confirmText?: string;
showCloseButton?: boolean;
}
export const Dialog = ({
isOpen,
onClose,
title,
description,
children,
onConfirm,
confirmText,
showCloseButton = true
}: DialogProps) => {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.95, y: 20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="relative w-full max-w-md p-6 bg-neutral-900 border border-neutral-800 rounded-lg shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{showCloseButton && (
<button
onClick={onClose}
className="absolute top-3 right-3 text-neutral-500 hover:text-neutral-100 transition-colors"
>
<X className="h-5 w-5" />
</button>
)}
<h2 className="text-lg font-bold font-mono text-neutral-100">{title}</h2>
{description && <p className="mt-2 text-sm text-neutral-400">{description}</p>}
<div className="mt-4">{children}</div>
{onConfirm && (
<div className="flex justify-end gap-2 pt-4 mt-4 border-t border-neutral-800">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button variant={confirmText?.toLowerCase().includes('delete') ? 'destructive' : 'default'} onClick={onConfirm}>
{confirmText || 'Confirm'}
</Button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@ -0,0 +1,27 @@
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-neutral-900 px-3 py-2 text-sm text-neutral-100',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-accent focus-visible:ring-offset-2 focus-visible:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

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 !== 'f030682d992c5e6c98594043c5a1f2763576f643f7bfbd940e325e46379c772f') {
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,65 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';
import { AlertTriangle, List, Loader2, Users, FileText } from 'lucide-react';
import { motion } from 'framer-motion';
interface StatCardProps {
title: string;
value: number | string;
icon: React.ElementType;
index: number;
}
const StatCard = ({ title, value, icon: Icon, index }: StatCardProps) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="bg-neutral-900 border border-neutral-800 p-6 rounded-lg"
>
<div className="flex justify-between items-start">
<div className="flex flex-col">
<p className="text-sm text-neutral-400 font-mono uppercase tracking-wider">{title}</p>
<span className="text-4xl font-bold text-neutral-100 mt-2">{value}</span>
</div>
<Icon className="h-8 w-8 text-neutral-600" />
</div>
</motion.div>
);
const DashboardPage = () => {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['adminDashboardStats'],
queryFn: async () => {
const res = await api.get('/api/admin/dashboard');
return res.data;
},
});
return (
<div className="container mx-auto">
<h1 className="text-3xl font-bold font-mono mb-8">Dashboard</h1>
{isLoading && (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-green-accent" />
</div>
)}
{isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg flex items-center">
<AlertTriangle className="h-5 w-5 mr-3" />
<span>Error fetching stats: {error.message}</span>
</div>
)}
{data && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard title="Pending Reports" value={data.pendingReports} icon={AlertTriangle} index={0} />
<StatCard title="Active Posts" value={data.activePosts} icon={List} index={1} />
<StatCard title="Posts Today" value={data.postsToday} icon={FileText} index={2} />
<StatCard title="Total Users" value={data.totalUsers} icon={Users} index={3} />
</div>
)}
</div>
);
};
export default DashboardPage;

View File

@ -0,0 +1,117 @@
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api, API_URL } from '../api';
import { toast } from 'sonner';
import { Button } from '../components/ui/button';
import { ArrowLeft, Trash2, CheckCircle, Play } from 'lucide-react';
import { useState } from 'react';
import { Dialog } from '../components/ui/dialog';
const PostContent = ({ post }: { post: any }) => {
if (!post) return <div className="text-neutral-500">No content to display.</div>;
const fullUrl = post.contentData?.startsWith('/api/') ? `${API_URL}${post.contentData}` : post.contentData;
switch (post.contentType) {
case 'text':
return <p className="text-lg text-neutral-200 whitespace-pre-wrap font-serif leading-relaxed">{post.contentData}</p>;
case 'image':
return <img src={fullUrl} alt="Reported content" className="max-w-full rounded-lg mx-auto" />;
case 'audio':
return (
<div className="flex items-center justify-center p-8 bg-neutral-800 rounded-lg">
<audio controls src={fullUrl} className="w-full">Your browser does not support the audio element.</audio>
</div>
);
default:
return <div className="text-neutral-500">Unknown content type: {post.contentType}</div>;
}
}
const ReportDetailPage = () => {
const { reportId } = useParams();
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { report } = location.state || {};
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const reviewMutation = useMutation({
mutationFn: (action: 'dismiss' | 'delete_post') => api.post(`/api/admin/reports/${reportId}/review`, { action }),
onSuccess: (_, action) => {
toast.success(action === 'dismiss' ? 'Report dismissed.' : 'Post deleted successfully.');
queryClient.invalidateQueries({ queryKey: ['adminReports'] });
navigate('/reports');
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Action failed.');
}
});
if (!report) {
// In a real app, you might fetch the report details here if not passed in state
return <div>Report not found. Go back to the queue.</div>;
}
return (
<div>
<Dialog
isOpen={isConfirmingDelete}
onClose={() => setIsConfirmingDelete(false)}
onConfirm={() => {
reviewMutation.mutate('delete_post');
setIsConfirmingDelete(false);
}}
title="Confirm Post Deletion"
description="This will permanently delete the post associated with this report. This action cannot be undone."
confirmText="Yes, Delete Post"
/>
<Button variant="ghost" onClick={() => navigate('/reports')} className="mb-8">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Queue
</Button>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 bg-neutral-900 border border-neutral-800 p-8 rounded-lg">
<h2 className="font-mono text-lg text-neutral-400 mb-4">Reported Content</h2>
<PostContent post={report.post} />
</div>
<div className="flex flex-col gap-4">
<div className="bg-neutral-900 border border-neutral-800 p-6 rounded-lg">
<h3 className="font-mono text-neutral-400 mb-2">Report Details</h3>
<p><strong className="font-medium text-neutral-100">Reason:</strong> {report.reason}</p>
<p><strong className="font-medium text-neutral-100">Emotion:</strong> {report.post.emotionTag}</p>
<p><strong className="font-medium text-neutral-100">Post ID:</strong> <span className="font-mono text-xs">{report.postId}</span></p>
</div>
<div className="bg-neutral-900 border border-neutral-800 p-6 rounded-lg flex flex-col gap-4">
<h3 className="font-mono text-neutral-400 mb-2">Actions</h3>
<Button
onClick={() => reviewMutation.mutate('dismiss')}
disabled={reviewMutation.isPending}
className="w-full"
variant="outline"
>
<CheckCircle className="h-4 w-4 mr-2" />
Dismiss Report
</Button>
<Button
onClick={() => setIsConfirmingDelete(true)}
disabled={reviewMutation.isPending}
className="w-full"
variant="destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Post
</Button>
</div>
</div>
</div>
</div>
);
};
export default ReportDetailPage;

View File

@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { Button } from '../components/ui/button';
import { Loader2, AlertTriangle, ChevronRight } from 'lucide-react';
const columnHelper = createColumnHelper<any>();
const columns = [
columnHelper.accessor('reportId', {
header: 'ID',
cell: info => <span className="font-mono text-neutral-400">#{info.getValue()}</span>,
}),
columnHelper.accessor('reason', {
header: 'Reason',
cell: info => <span className="text-neutral-100">{info.getValue()}</span>,
}),
columnHelper.accessor('reportedAt', {
header: 'Reported',
cell: info => <span className="text-neutral-400">{formatDistanceToNow(new Date(info.getValue()), { addSuffix: true })}</span>,
}),
columnHelper.accessor('actions', {
header: 'Actions',
cell: ({ row }) => {
const navigate = useNavigate();
return (
<Button variant="outline" size="sm" onClick={() => navigate(`/reports/${row.original.reportId}`, { state: { report: row.original } })}>
Review
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
);
},
}),
];
const ReportsQueuePage = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ['adminReports'],
queryFn: async () => {
const res = await api.get('/api/admin/reports');
return res.data.reports;
},
});
const table = useReactTable({
data: data || [],
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div>
<h1 className="text-3xl font-bold font-mono mb-8">Reports Queue</h1>
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-green-accent" />
</div>
) : isError ? (
<div className="flex items-center text-red-400 p-8">
<AlertTriangle className="h-5 w-5 mr-3" />
<span>Failed to load reports.</span>
</div>
) : table.getRowModel().rows.length === 0 ? (
<div className="flex items-center justify-center text-neutral-500 p-12">
<p>No pending reports. The queue is clear.</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-neutral-800/50">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id} className="p-4 text-left font-mono text-neutral-400 uppercase tracking-wider">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="border-b border-neutral-800 last:border-b-0 hover:bg-neutral-800/30 transition-colors">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="p-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default ReportsQueuePage;

View File

@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { api } from '../api';
import { toast } from 'sonner';
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Button } from '../components/ui/button';
import { Dialog } from '../components/ui/dialog';
import { Input } from '../components/ui/input';
import { Loader2, AlertTriangle, UserPlus, Pencil, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
const userSchema = z.object({
fullName: z.string().min(2, 'Full name is required'),
email: z.string().email('Invalid email address'),
role: z.enum(['admin', 'moderator']),
});
type UserFormData = z.infer<typeof userSchema>;
type User = UserFormData & { created_at: string };
const columnHelper = createColumnHelper<User>();
const UserForm = ({ user, onClose }: { user: User | null; onClose: () => void }) => {
const queryClient = useQueryClient();
const isEditing = !!user;
const { register, handleSubmit, formState: { errors }, reset } = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: user ? { fullName: user.fullName, email: user.email, role: user.role } : { role: 'moderator' },
});
const mutation = useMutation({
mutationFn: (data: UserFormData) => {
return isEditing ? api.put(`/api/admin/users/${user.email}`, data) : api.post('/api/admin/users', data);
},
onSuccess: () => {
toast.success(isEditing ? 'User updated successfully' : 'User created successfully');
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
onClose();
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'An error occurred');
}
});
useEffect(() => {
reset(user ? { fullName: user.fullName, email: user.email, role: user.role } : { role: 'moderator' });
}, [user, reset]);
const onSubmit = (data: UserFormData) => mutation.mutate(data);
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="text-xs font-mono text-neutral-400">Full Name</label>
<Input {...register('fullName')} className="mt-1" />
{errors.fullName && <p className="text-red-400 text-sm mt-1">{errors.fullName.message}</p>}
</div>
<div>
<label className="text-xs font-mono text-neutral-400">Email</label>
<Input {...register('email')} disabled={isEditing} className="mt-1" />
{errors.email && <p className="text-red-400 text-sm mt-1">{errors.email.message}</p>}
</div>
<div>
<label className="text-xs font-mono text-neutral-400">Role</label>
<select {...register('role')} className="w-full mt-1 p-2 bg-neutral-800 border border-neutral-700 rounded-md focus:ring-2 focus:ring-green-accent focus:border-green-accent outline-none">
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
{errors.role && <p className="text-red-400 text-sm mt-1">{errors.role.message}</p>}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? <Loader2 className="animate-spin h-4 w-4" /> : 'Save'}</Button>
</div>
</form>
);
};
const UserManagementPage = () => {
const [isFormOpen, setIsFormOpen] = useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const queryClient = useQueryClient();
const { data: users, isLoading, isError } = useQuery<User[]>({
queryKey: ['adminUsers'],
queryFn: async () => {
const res = await api.get('/api/admin/users');
return res.data;
},
});
const deleteMutation = useMutation({
mutationFn: (email: string) => api.delete(`/api/admin/users/${email}`),
onSuccess: () => {
toast.success('User deleted successfully');
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
setIsDeleteConfirmOpen(false);
setSelectedUser(null);
},
onError: (error: any) => {
toast.error(error.response?.data?.error || 'Failed to delete user');
}
});
const columns = [
columnHelper.accessor('fullName', {
header: 'User',
cell: info => (
<div className='flex flex-col'>
<span className='font-medium text-neutral-100'>{info.getValue()}</span>
<span className='text-xs text-neutral-400 font-mono'>{info.row.original.email}</span>
</div>
)
}),
columnHelper.accessor('role', {
header: 'Role',
cell: info => <span className={cn('px-2 py-1 text-xs rounded-full font-mono', info.getValue() === 'admin' ? 'bg-green-accent/10 text-green-accent' : 'bg-neutral-700 text-neutral-300')}>{info.getValue()}</span>,
}),
columnHelper.accessor('created_at', {
header: 'Created',
cell: info => <span className="text-neutral-400">{format(new Date(info.getValue()), 'MMM d, yyyy')}</span>,
}),
columnHelper.accessor('email', {
header: 'Actions',
id: 'actions',
cell: ({ row }) => (
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => { setSelectedUser(row.original); setIsFormOpen(true); }}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-400 hover:bg-red-500/10" onClick={() => { setSelectedUser(row.original); setIsDeleteConfirmOpen(true); }}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
),
}),
];
const table = useReactTable({ data: users || [], columns, getCoreRowModel: getCoreRowModel() });
return (
<div>
<Dialog
isOpen={isFormOpen}
onClose={() => { setIsFormOpen(false); setSelectedUser(null); }}
title={selectedUser ? 'Edit User' : 'Create New User'}
showCloseButton={false}
>
<UserForm user={selectedUser} onClose={() => { setIsFormOpen(false); setSelectedUser(null); }} />
</Dialog>
<Dialog
isOpen={isDeleteConfirmOpen}
onClose={() => { setIsDeleteConfirmOpen(false); setSelectedUser(null); }}
title="Confirm Deletion"
description={`Are you sure you want to delete the user ${selectedUser?.email}? This action cannot be undone.`}
confirmText="Yes, Delete User"
onConfirm={() => selectedUser && deleteMutation.mutate(selectedUser.email)}
/>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold font-mono">User Management</h1>
<Button onClick={() => { setSelectedUser(null); setIsFormOpen(true); }}>
<UserPlus className="h-4 w-4 mr-2" />
New User
</Button>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center p-12"><Loader2 className="h-8 w-8 animate-spin text-green-accent" /></div>
) : isError ? (
<div className="flex items-center text-red-400 p-8"><AlertTriangle className="h-5 w-5 mr-3" /><span>Failed to load users.</span></div>
) : (
<table className="w-full text-sm">
<thead className="bg-neutral-800/50">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id} className="p-4 text-left font-mono text-neutral-400 uppercase tracking-wider">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="border-b border-neutral-800 last:border-b-0 hover:bg-neutral-800/30 transition-colors">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="p-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default UserManagementPage;

View File

@ -0,0 +1,4 @@
export { default as DashboardPage } from './DashboardPage';
export { default as ReportsQueuePage } from './ReportsQueuePage';
export { default as ReportDetailPage } from './ReportDetailPage';
export { default as UserManagementPage } from './UserManagementPage';

View File

@ -0,0 +1,23 @@
import { create } from 'zustand';
interface User {
email: string;
fullName: string;
role: 'admin' | 'moderator';
}
interface UserState {
user: User | null;
_setUser: (user: User) => void;
}
const mockAdminUser: User = {
email: 'admin@dissolve.app',
fullName: 'Admin User',
role: 'admin',
};
export const useUserStore = create<UserState>((set) => ({
user: mockAdminUser, // Initialize with a mock admin for access to all features
_setUser: (user) => set({ user }),
}));

View File

@ -0,0 +1,51 @@
import tailwindAnimate from "tailwindcss-animate";
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
},
},
fontFamily: {
body: ["Inter", "system-ui", "sans-serif"],
display: ["Cal Sans", "Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [tailwindAnimate],
}

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);
},
},
},
})