feat: add admin frontend in /admin-frontend
This commit is contained in:
parent
384eb31752
commit
1112e6f062
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>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
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-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"
|
||||
}
|
||||
}
|
||||
1
admin-frontend/postcss.config.js
Normal file
1
admin-frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
37
admin-frontend/src/App.tsx
Normal file
37
admin-frontend/src/App.tsx
Normal 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
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-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);
|
||||
}
|
||||
);
|
||||
26
admin-frontend/src/components/Layout.tsx
Normal file
26
admin-frontend/src/components/Layout.tsx
Normal 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;
|
||||
55
admin-frontend/src/components/Sidebar.tsx
Normal file
55
admin-frontend/src/components/Sidebar.tsx
Normal 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;
|
||||
49
admin-frontend/src/components/ui/button.tsx
Normal file
49
admin-frontend/src/components/ui/button.tsx
Normal 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 };
|
||||
72
admin-frontend/src/components/ui/dialog.tsx
Normal file
72
admin-frontend/src/components/ui/dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
admin-frontend/src/components/ui/input.tsx
Normal file
27
admin-frontend/src/components/ui/input.tsx
Normal 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 };
|
||||
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 !== '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.');
|
||||
});
|
||||
65
admin-frontend/src/pages/DashboardPage.tsx
Normal file
65
admin-frontend/src/pages/DashboardPage.tsx
Normal 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;
|
||||
117
admin-frontend/src/pages/ReportDetailPage.tsx
Normal file
117
admin-frontend/src/pages/ReportDetailPage.tsx
Normal 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;
|
||||
102
admin-frontend/src/pages/ReportsQueuePage.tsx
Normal file
102
admin-frontend/src/pages/ReportsQueuePage.tsx
Normal 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;
|
||||
212
admin-frontend/src/pages/UserManagementPage.tsx
Normal file
212
admin-frontend/src/pages/UserManagementPage.tsx
Normal 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;
|
||||
4
admin-frontend/src/pages/index.ts
Normal file
4
admin-frontend/src/pages/index.ts
Normal 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';
|
||||
23
admin-frontend/src/stores/userStore.ts
Normal file
23
admin-frontend/src/stores/userStore.ts
Normal 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 }),
|
||||
}));
|
||||
51
admin-frontend/tailwind.config.js
Normal file
51
admin-frontend/tailwind.config.js
Normal 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],
|
||||
}
|
||||
|
||||
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