Initial commit: File Transformer S3 project with React dashboard and Knative functions

This commit is contained in:
greg
2025-07-04 08:01:46 -07:00
commit fd9abd0210
54 changed files with 5584 additions and 0 deletions

35
dashboard/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# Build stage
FROM node:18-alpine as build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (use npm install instead of npm ci for better compatibility)
RUN npm install --only=production --no-optional
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built application
COPY --from=build /app/build /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000 || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

72
dashboard/nginx.conf Normal file
View File

@@ -0,0 +1,72 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

62
dashboard/package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "file-transformer-dashboard",
"version": "1.0.0",
"description": "React dashboard for File Transformer S3",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"react-router-dom": "^6.3.0",
"axios": "^1.4.0",
"react-dropzone": "^14.2.3",
"react-query": "^3.39.3",
"react-hot-toast": "^2.4.0",
"lucide-react": "^0.263.1",
"clsx": "^1.2.1",
"tailwindcss": "^3.3.2",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"recharts": "^2.7.2",
"date-fns": "^2.30.0",
"react-hook-form": "^7.45.1",
"react-select": "^5.7.3",
"react-table": "^7.8.0",
"framer-motion": "^10.12.16"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"typescript": "^4.9.5"
},
"proxy": "http://localhost:8080"
}

115
dashboard/src/App.css Normal file
View File

@@ -0,0 +1,115 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
}
.input-field {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
}
.table-header {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}
.table-cell {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Loading animation */
.loading-spinner {
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* File upload dropzone */
.dropzone {
border: 2px dashed #d1d5db;
border-radius: 8px;
padding: 40px;
text-align: center;
transition: border-color 0.2s ease;
}
.dropzone:hover {
border-color: #3b82f6;
}
.dropzone.drag-active {
border-color: #3b82f6;
background-color: #eff6ff;
}
/* Status badges */
.status-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.status-uploaded {
@apply bg-blue-100 text-blue-800;
}
.status-processing {
@apply bg-yellow-100 text-yellow-800;
}
.status-transformed {
@apply bg-green-100 text-green-800;
}
.status-error {
@apply bg-red-100 text-red-800;
}
.status-deleted {
@apply bg-gray-100 text-gray-800;
}

67
dashboard/src/App.js Normal file
View File

@@ -0,0 +1,67 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Toaster } from 'react-hot-toast';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Files from './pages/Files';
import Upload from './pages/Upload';
import Transformations from './pages/Transformations';
import Buckets from './pages/Buckets';
import Settings from './pages/Settings';
import Login from './pages/Login';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import './App.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router>
<div className="App">
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
}}
/>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="files" element={<Files />} />
<Route path="upload" element={<Upload />} />
<Route path="transformations" element={<Transformations />} />
<Route path="buckets" element={<Buckets />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</div>
</Router>
</AuthProvider>
</QueryClientProvider>
);
}
export default App;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { BellIcon } from '@heroicons/react/24/outline';
const Header = () => {
return (
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
<div className="flex items-center">
<h2 className="text-lg font-medium text-gray-900">File Transformer S3</h2>
</div>
<div className="flex items-center space-x-4">
<button
type="button"
className="p-2 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<BellIcon className="h-6 w-6" />
</button>
<div className="flex items-center space-x-3">
<div className="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center">
<span className="text-sm font-medium text-white">A</span>
</div>
<span className="text-sm font-medium text-gray-700">Admin</span>
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import Header from './Header';
const Layout = () => {
return (
<div className="flex h-screen bg-gray-50">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-6">
<Outlet />
</main>
</div>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
const ProtectedRoute = ({ children }) => {
// For now, always allow access. In a real app, you'd check authentication here
const isAuthenticated = true; // Replace with actual auth check
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import {
HomeIcon,
DocumentTextIcon,
CloudArrowUpIcon,
CogIcon,
FolderIcon,
ChartBarIcon
} from '@heroicons/react/24/outline';
const Sidebar = () => {
const navigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Files', href: '/files', icon: DocumentTextIcon },
{ name: 'Upload', href: '/upload', icon: CloudArrowUpIcon },
{ name: 'Transformations', href: '/transformations', icon: CogIcon },
{ name: 'Buckets', href: '/buckets', icon: FolderIcon },
{ name: 'Analytics', href: '/analytics', icon: ChartBarIcon },
{ name: 'Settings', href: '/settings', icon: CogIcon },
];
return (
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-white border-r border-gray-200">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0 px-4">
<h1 className="text-xl font-semibold text-gray-900">File Transformer</h1>
</div>
<nav className="mt-5 flex-1 px-2 space-y-1">
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive
? 'bg-blue-100 text-blue-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`
}
>
<item.icon
className="mr-3 flex-shrink-0 h-6 w-6"
aria-hidden="true"
/>
{item.name}
</NavLink>
))}
</nav>
</div>
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,66 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for existing token
const token = localStorage.getItem('authToken');
if (token) {
// In a real app, validate the token with the backend
setUser({ id: '1', username: 'admin', role: 'admin' });
}
setLoading(false);
}, []);
const login = async (credentials) => {
try {
// In a real app, make API call to login
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('authToken', data.token);
setUser(data.user);
return { success: true };
} else {
return { success: false, error: 'Invalid credentials' };
}
} catch (error) {
return { success: false, error: 'Network error' };
}
};
const logout = () => {
localStorage.removeItem('authToken');
setUser(null);
};
const value = {
user,
login,
logout,
loading,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

20
dashboard/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f9fafb;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}

11
dashboard/src/index.js Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { useQuery } from 'react-query';
import { FolderIcon, PlusIcon } from '@heroicons/react/24/outline';
import { bucketsAPI } from '../services/api';
const Buckets = () => {
const { data: buckets, isLoading, error } = useQuery('buckets', bucketsAPI.getBuckets);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="loading-spinner"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600">Error loading buckets: {error.message}</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Buckets</h1>
<p className="text-gray-600">Manage MinIO storage buckets</p>
</div>
<button className="btn-primary flex items-center">
<PlusIcon className="w-4 h-4 mr-2" />
Create Bucket
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{buckets?.map((bucket) => (
<div key={bucket.name} className="card hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center">
<FolderIcon className="w-8 h-8 text-blue-500 mr-3" />
<div>
<h3 className="text-lg font-medium text-gray-900">{bucket.name}</h3>
<p className="text-sm text-gray-500">{bucket.description || 'No description'}</p>
</div>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
bucket.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{bucket.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Created</span>
<span className="text-gray-900">
{new Date(bucket.created_at).toLocaleDateString()}
</span>
</div>
</div>
<div className="mt-4 flex space-x-2">
<button className="btn-secondary text-sm">View Files</button>
<button className="btn-secondary text-sm">Settings</button>
</div>
</div>
))}
</div>
{buckets?.length === 0 && (
<div className="text-center py-12">
<FolderIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No buckets</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating a new bucket.
</p>
<div className="mt-6">
<button className="btn-primary">
<PlusIcon className="w-4 h-4 mr-2" />
Create Bucket
</button>
</div>
</div>
)}
</div>
);
};
export default Buckets;

View File

@@ -0,0 +1,223 @@
import React from 'react';
import { useQuery } from 'react-query';
import {
CloudArrowUpIcon,
DocumentTextIcon,
CogIcon,
ExclamationTriangleIcon,
ArrowUpIcon,
ArrowDownIcon
} from '@heroicons/react/24/outline';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { format } from 'date-fns';
import { motion } from 'framer-motion';
import { getDashboardStats, getRecentFiles, getRecentTransformations } from '../services/api';
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
const Dashboard = () => {
const { data: stats, isLoading: statsLoading } = useQuery('dashboardStats', getDashboardStats);
const { data: recentFiles, isLoading: filesLoading } = useQuery('recentFiles', getRecentFiles);
const { data: recentTransformations, isLoading: transformationsLoading } = useQuery('recentTransformations', getRecentTransformations);
const fileTypeData = [
{ name: 'PDF', value: stats?.fileTypes?.pdf || 0 },
{ name: 'DOC', value: stats?.fileTypes?.doc || 0 },
{ name: 'TXT', value: stats?.fileTypes?.txt || 0 },
{ name: 'CSV', value: stats?.fileTypes?.csv || 0 },
];
const statusData = [
{ name: 'Uploaded', value: stats?.statusCounts?.uploaded || 0 },
{ name: 'Processing', value: stats?.statusCounts?.processing || 0 },
{ name: 'Transformed', value: stats?.statusCounts?.transformed || 0 },
{ name: 'Error', value: stats?.statusCounts?.error || 0 },
];
const StatCard = ({ title, value, icon: Icon, change, changeType = 'up' }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="card"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-2xl font-bold text-gray-900">{value}</p>
{change && (
<div className="flex items-center mt-2">
{changeType === 'up' ? (
<ArrowUpIcon className="w-4 h-4 text-green-500" />
) : (
<ArrowDownIcon className="w-4 h-4 text-red-500" />
)}
<span className={`text-sm font-medium ${
changeType === 'up' ? 'text-green-600' : 'text-red-600'
}`}>
{change}
</span>
</div>
)}
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<Icon className="w-6 h-6 text-blue-600" />
</div>
</div>
</motion.div>
);
if (statsLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="loading-spinner"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Overview of your file transformation system</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title="Total Files"
value={stats?.totalFiles || 0}
icon={DocumentTextIcon}
change="+12%"
changeType="up"
/>
<StatCard
title="Storage Used"
value={`${(stats?.totalSize / (1024 * 1024 * 1024)).toFixed(1)} GB`}
icon={CloudArrowUpIcon}
change="+8%"
changeType="up"
/>
<StatCard
title="Active Transformations"
value={stats?.activeTransformations || 0}
icon={CogIcon}
change="-3%"
changeType="down"
/>
<StatCard
title="Failed Jobs"
value={stats?.failedJobs || 0}
icon={ExclamationTriangleIcon}
change="+2%"
changeType="up"
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File Types Chart */}
<div className="card">
<h3 className="text-lg font-semibold text-gray-900 mb-4">File Types Distribution</h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={fileTypeData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{fileTypeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
{/* Status Chart */}
<div className="card">
<h3 className="text-lg font-semibold text-gray-900 mb-4">File Status Overview</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={statusData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Files */}
<div className="card">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Files</h3>
{filesLoading ? (
<div className="flex items-center justify-center h-32">
<div className="loading-spinner"></div>
</div>
) : (
<div className="space-y-3">
{recentFiles?.slice(0, 5).map((file) => (
<div key={file.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<DocumentTextIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-900">{file.filename}</p>
<p className="text-xs text-gray-500">
{format(new Date(file.created_at), 'MMM dd, yyyy HH:mm')}
</p>
</div>
</div>
<span className={`status-badge status-${file.status}`}>
{file.status}
</span>
</div>
))}
</div>
)}
</div>
{/* Recent Transformations */}
<div className="card">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Transformations</h3>
{transformationsLoading ? (
<div className="flex items-center justify-center h-32">
<div className="loading-spinner"></div>
</div>
) : (
<div className="space-y-3">
{recentTransformations?.slice(0, 5).map((transformation) => (
<div key={transformation.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<CogIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-900">{transformation.transformation_type}</p>
<p className="text-xs text-gray-500">
{format(new Date(transformation.created_at), 'MMM dd, yyyy HH:mm')}
</p>
</div>
</div>
<span className={`status-badge status-${transformation.status}`}>
{transformation.status}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { useQuery } from 'react-query';
import { DocumentTextIcon, ArrowDownTrayIcon, TrashIcon } from '@heroicons/react/24/outline';
import { filesAPI } from '../services/api';
const Files = () => {
const { data: files, isLoading, error } = useQuery('files', filesAPI.getFiles);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="loading-spinner"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600">Error loading files: {error.message}</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Files</h1>
<p className="text-gray-600">Manage your uploaded files</p>
</div>
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">All Files</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="table-header">File</th>
<th className="table-header">Size</th>
<th className="table-header">Type</th>
<th className="table-header">Status</th>
<th className="table-header">Uploaded</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{files?.map((file) => (
<tr key={file.id} className="hover:bg-gray-50">
<td className="table-cell">
<div className="flex items-center">
<DocumentTextIcon className="w-5 h-5 text-gray-400 mr-3" />
<div>
<div className="text-sm font-medium text-gray-900">
{file.filename}
</div>
<div className="text-sm text-gray-500">
{file.original_filename}
</div>
</div>
</div>
</td>
<td className="table-cell">
<span className="text-sm text-gray-900">
{(file.file_size / 1024 / 1024).toFixed(2)} MB
</span>
</td>
<td className="table-cell">
<span className="text-sm text-gray-900">{file.file_type}</span>
</td>
<td className="table-cell">
<span className={`status-badge status-${file.status}`}>
{file.status}
</span>
</td>
<td className="table-cell">
<span className="text-sm text-gray-900">
{new Date(file.created_at).toLocaleDateString()}
</span>
</td>
<td className="table-cell">
<div className="flex space-x-2">
<button className="text-blue-600 hover:text-blue-900">
<ArrowDownTrayIcon className="w-4 h-4" />
</button>
<button className="text-red-600 hover:text-red-900">
<TrashIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Files;

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import toast from 'react-hot-toast';
const Login = () => {
const [credentials, setCredentials] = useState({
username: '',
password: ''
});
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const result = await login(credentials);
if (result.success) {
toast.success('Login successful!');
navigate('/');
} else {
toast.error(result.error || 'Login failed');
}
} catch (error) {
toast.error('An error occurred during login');
} finally {
setLoading(false);
}
};
const handleChange = (e) => {
setCredentials({
...credentials,
[e.target.name]: e.target.value
});
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to File Transformer
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Access your file transformation dashboard
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Username
</label>
<input
id="username"
name="username"
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Username"
value={credentials.username}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={credentials.password}
onChange={handleChange}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="loading-spinner"></div>
) : (
'Sign in'
)}
</button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Default credentials: admin / admin123
</p>
</div>
</form>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { CogIcon, ShieldCheckIcon, CircleStackIcon, CloudIcon } from '@heroicons/react/24/outline';
const Settings = () => {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="text-gray-600">Configure your file transformation system</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* System Configuration */}
<div className="card">
<div className="flex items-center mb-4">
<CogIcon className="w-6 h-6 text-blue-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">System Configuration</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Application Name
</label>
<input
type="text"
className="input-field"
defaultValue="File Transformer S3"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Environment
</label>
<select className="input-field">
<option value="development">Development</option>
<option value="staging">Staging</option>
<option value="production">Production</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Debug Mode
</label>
<div className="flex items-center">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
defaultChecked
/>
<label className="ml-2 text-sm text-gray-700">Enable debug logging</label>
</div>
</div>
</div>
</div>
{/* Security Settings */}
<div className="card">
<div className="flex items-center mb-4">
<ShieldCheckIcon className="w-6 h-6 text-green-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Security</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Session Timeout (hours)
</label>
<input
type="number"
className="input-field"
defaultValue="24"
min="1"
max="168"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max File Size (MB)
</label>
<input
type="number"
className="input-field"
defaultValue="100"
min="1"
max="1000"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Allowed File Types
</label>
<input
type="text"
className="input-field"
defaultValue="pdf,doc,docx,txt,csv,xlsx,xls,json,xml"
placeholder="Comma-separated file extensions"
/>
</div>
</div>
</div>
{/* Database Configuration */}
<div className="card">
<div className="flex items-center mb-4">
<CircleStackIcon className="w-6 h-6 text-purple-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Database</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Database Host
</label>
<input
type="text"
className="input-field"
defaultValue="localhost"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Database Port
</label>
<input
type="number"
className="input-field"
defaultValue="5432"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Database Name
</label>
<input
type="text"
className="input-field"
defaultValue="file_transformer"
/>
</div>
</div>
</div>
{/* Storage Configuration */}
<div className="card">
<div className="flex items-center mb-4">
<CloudIcon className="w-6 h-6 text-orange-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Storage</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
MinIO Endpoint
</label>
<input
type="text"
className="input-field"
defaultValue="localhost:9000"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Default Bucket
</label>
<input
type="text"
className="input-field"
defaultValue="file-transformer-bucket"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Use SSL
</label>
<div className="flex items-center">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label className="ml-2 text-sm text-gray-700">Enable SSL for MinIO</label>
</div>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-4">
<button className="btn-secondary">Reset to Defaults</button>
<button className="btn-primary">Save Settings</button>
</div>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { useQuery } from 'react-query';
import { CogIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '@heroicons/react/24/outline';
import { transformationsAPI } from '../services/api';
const Transformations = () => {
const { data: transformations, isLoading, error } = useQuery('transformations', transformationsAPI.getTransformations);
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
case 'failed':
return <XCircleIcon className="w-5 h-5 text-red-500" />;
case 'processing':
return <CogIcon className="w-5 h-5 text-blue-500 animate-spin" />;
default:
return <ClockIcon className="w-5 h-5 text-gray-500" />;
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="loading-spinner"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600">Error loading transformations: {error.message}</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Transformations</h1>
<p className="text-gray-600">Monitor file transformation jobs</p>
</div>
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">All Transformations</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="table-header">Type</th>
<th className="table-header">File</th>
<th className="table-header">Status</th>
<th className="table-header">Started</th>
<th className="table-header">Completed</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{transformations?.map((transformation) => (
<tr key={transformation.id} className="hover:bg-gray-50">
<td className="table-cell">
<span className="text-sm font-medium text-gray-900">
{transformation.transformation_type}
</span>
</td>
<td className="table-cell">
<span className="text-sm text-gray-900">
{transformation.file_id}
</span>
</td>
<td className="table-cell">
<div className="flex items-center">
{getStatusIcon(transformation.status)}
<span className={`ml-2 status-badge status-${transformation.status}`}>
{transformation.status}
</span>
</div>
</td>
<td className="table-cell">
<span className="text-sm text-gray-900">
{transformation.started_at
? new Date(transformation.started_at).toLocaleString()
: '-'
}
</span>
</td>
<td className="table-cell">
<span className="text-sm text-gray-900">
{transformation.completed_at
? new Date(transformation.completed_at).toLocaleString()
: '-'
}
</span>
</td>
<td className="table-cell">
<div className="flex space-x-2">
{transformation.status === 'failed' && (
<button className="text-blue-600 hover:text-blue-900 text-sm">
Retry
</button>
)}
<button className="text-gray-600 hover:text-gray-900 text-sm">
View Logs
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Transformations;

View File

@@ -0,0 +1,159 @@
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { CloudArrowUpIcon, DocumentTextIcon } from '@heroicons/react/24/outline';
import { uploadFileWithProgress } from '../services/api';
import toast from 'react-hot-toast';
const Upload = () => {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState({});
const onDrop = useCallback(async (acceptedFiles) => {
setUploading(true);
for (const file of acceptedFiles) {
try {
setUploadProgress(prev => ({ ...prev, [file.name]: 0 }));
await uploadFileWithProgress(file, (progress) => {
setUploadProgress(prev => ({ ...prev, [file.name]: progress }));
});
toast.success(`${file.name} uploaded successfully!`);
setUploadProgress(prev => {
const newProgress = { ...prev };
delete newProgress[file.name];
return newProgress;
});
} catch (error) {
toast.error(`Failed to upload ${file.name}: ${error.message}`);
setUploadProgress(prev => {
const newProgress = { ...prev };
delete newProgress[file.name];
return newProgress;
});
}
}
setUploading(false);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/msword': ['.doc'],
'text/plain': ['.txt'],
'text/csv': ['.csv'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
'application/json': ['.json'],
'application/xml': ['.xml'],
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff']
},
maxSize: 100 * 1024 * 1024, // 100MB
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Upload Files</h1>
<p className="text-gray-600">Upload files for transformation and processing</p>
</div>
<div className="card">
<div
{...getRootProps()}
className={`dropzone ${isDragActive ? 'drag-active' : ''} ${
uploading ? 'opacity-50 pointer-events-none' : ''
}`}
>
<input {...getInputProps()} />
<CloudArrowUpIcon className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4 text-center">
{isDragActive ? (
<p className="text-lg text-blue-600">Drop the files here...</p>
) : (
<>
<p className="text-lg text-gray-900">
Drag and drop files here, or click to select files
</p>
<p className="text-sm text-gray-500 mt-2">
Supports PDF, DOC, TXT, CSV, Excel, JSON, XML, and image files (max 100MB)
</p>
</>
)}
</div>
</div>
</div>
{/* Upload Progress */}
{Object.keys(uploadProgress).length > 0 && (
<div className="card">
<h3 className="text-lg font-medium text-gray-900 mb-4">Upload Progress</h3>
<div className="space-y-3">
{Object.entries(uploadProgress).map(([filename, progress]) => (
<div key={filename} className="flex items-center space-x-3">
<DocumentTextIcon className="w-5 h-5 text-gray-400" />
<div className="flex-1">
<div className="flex justify-between text-sm">
<span className="text-gray-900">{filename}</span>
<span className="text-gray-500">{progress}%</span>
</div>
<div className="mt-1 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* File Type Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card">
<h3 className="text-lg font-medium text-gray-900 mb-4">Supported File Types</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Documents</span>
<span className="text-gray-900">PDF, DOC, DOCX, TXT</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Data Files</span>
<span className="text-gray-900">CSV, XLS, XLSX, JSON, XML</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Images</span>
<span className="text-gray-900">JPG, PNG, GIF, BMP, TIFF</span>
</div>
</div>
</div>
<div className="card">
<h3 className="text-lg font-medium text-gray-900 mb-4">Transformation Options</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Text Extraction</span>
<span className="text-green-600">Available</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Format Conversion</span>
<span className="text-green-600">Available</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Image Processing</span>
<span className="text-green-600">Available</span>
</div>
</div>
</div>
</div>
</div>
);
};
export default Upload;

View File

@@ -0,0 +1,180 @@
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080';
// Create axios instance with default config
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('authToken');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Auth API
export const authAPI = {
login: (credentials) => api.post('/auth/login', credentials),
logout: () => api.post('/auth/logout'),
register: (userData) => api.post('/auth/register', userData),
getProfile: () => api.get('/auth/profile'),
};
// Files API
export const filesAPI = {
getFiles: (params = {}) => api.get('/files', { params }),
getFile: (id) => api.get(`/files/${id}`),
uploadFile: (file, onProgress) => {
const formData = new FormData();
formData.append('file', file);
return api.post('/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: onProgress,
});
},
deleteFile: (id) => api.delete(`/files/${id}`),
downloadFile: (id) => api.get(`/files/${id}/download`, { responseType: 'blob' }),
updateFile: (id, data) => api.put(`/files/${id}`, data),
getFileMetadata: (id) => api.get(`/files/${id}/metadata`),
};
// Transformations API
export const transformationsAPI = {
getTransformations: (params = {}) => api.get('/transformations', { params }),
getTransformation: (id) => api.get(`/transformations/${id}`),
createTransformation: (data) => api.post('/transformations', data),
updateTransformation: (id, data) => api.put(`/transformations/${id}`, data),
deleteTransformation: (id) => api.delete(`/transformations/${id}`),
retryTransformation: (id) => api.post(`/transformations/${id}/retry`),
getTransformationLogs: (id) => api.get(`/transformations/${id}/logs`),
};
// Buckets API
export const bucketsAPI = {
getBuckets: () => api.get('/buckets'),
getBucket: (name) => api.get(`/buckets/${name}`),
createBucket: (data) => api.post('/buckets', data),
deleteBucket: (name) => api.delete(`/buckets/${name}`),
getBucketStats: (name) => api.get(`/buckets/${name}/stats`),
getBucketFiles: (name, params = {}) => api.get(`/buckets/${name}/files`, { params }),
};
// Dashboard API
export const dashboardAPI = {
getStats: () => api.get('/dashboard/stats'),
getRecentFiles: (limit = 10) => api.get('/dashboard/recent-files', { params: { limit } }),
getRecentTransformations: (limit = 10) => api.get('/dashboard/recent-transformations', { params: { limit } }),
getFileTypeStats: () => api.get('/dashboard/file-types'),
getStatusStats: () => api.get('/dashboard/status-counts'),
getStorageStats: () => api.get('/dashboard/storage'),
};
// MinIO API (direct integration)
export const minioAPI = {
getBuckets: () => api.get('/minio/buckets'),
getObjects: (bucketName, prefix = '') => api.get(`/minio/buckets/${bucketName}/objects`, { params: { prefix } }),
uploadObject: (bucketName, objectKey, file) => {
const formData = new FormData();
formData.append('file', file);
formData.append('objectKey', objectKey);
return api.post(`/minio/buckets/${bucketName}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
},
deleteObject: (bucketName, objectKey) => api.delete(`/minio/buckets/${bucketName}/objects/${objectKey}`),
getObjectUrl: (bucketName, objectKey) => api.get(`/minio/buckets/${bucketName}/objects/${objectKey}/url`),
};
// Convenience functions for common operations
export const getDashboardStats = async () => {
const [stats, fileTypes, statusCounts, storage] = await Promise.all([
dashboardAPI.getStats(),
dashboardAPI.getFileTypeStats(),
dashboardAPI.getStatusStats(),
dashboardAPI.getStorageStats(),
]);
return {
...stats.data,
fileTypes: fileTypes.data,
statusCounts: statusCounts.data,
storage: storage.data,
};
};
export const getRecentFiles = async () => {
const response = await dashboardAPI.getRecentFiles();
return response.data;
};
export const getRecentTransformations = async () => {
const response = await dashboardAPI.getRecentTransformations();
return response.data;
};
export const uploadFileWithProgress = (file, onProgress) => {
return filesAPI.uploadFile(file, (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percentCompleted);
});
};
export const downloadFileAsBlob = async (fileId, filename) => {
const response = await filesAPI.downloadFile(fileId);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};
// Error handling utility
export const handleAPIError = (error) => {
if (error.response) {
// Server responded with error status
const message = error.response.data?.message || error.response.data?.error || 'An error occurred';
return { error: true, message, status: error.response.status };
} else if (error.request) {
// Request was made but no response received
return { error: true, message: 'Network error. Please check your connection.', status: 0 };
} else {
// Something else happened
return { error: true, message: error.message || 'An unexpected error occurred.', status: 0 };
}
};
export default api;

View File

@@ -0,0 +1,43 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}