Ensure data management page grabs progress of any running scripts on load, clean up unneeded console logs, restyle login page
This commit is contained in:
@@ -2,9 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/box.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/cherrybottom.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Inventory Manager</title>
|
||||
<title>A Cherry On Bottom</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7l8.7 5l8.7-5M12 22V12"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 340 B |
BIN
inventory/public/cherrybottom.ico
Normal file
BIN
inventory/public/cherrybottom.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
inventory/public/cherrybottom.png
Normal file
BIN
inventory/public/cherrybottom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -1,42 +1,3 @@
|
||||
#root {
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Package,
|
||||
BarChart2,
|
||||
Settings,
|
||||
Box,
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
Users,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
useSidebar
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import { Protected } from "@/components/auth/Protected";
|
||||
@@ -80,6 +80,7 @@ const items = [
|
||||
export function AppSidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
useSidebar();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
@@ -90,11 +91,19 @@ export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="sidebar">
|
||||
<SidebarHeader>
|
||||
<div className="p-4 flex items-center gap-2 group-data-[collapsible=icon]:justify-center">
|
||||
<Box className="h-6 w-6 shrink-0" />
|
||||
<h2 className="text-lg font-semibold group-data-[collapsible=icon]:hidden">
|
||||
Inventory Manager
|
||||
</h2>
|
||||
<div className="py-1 flex justify-center items-center">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 w-8 h-8 relative flex items-center justify-center">
|
||||
<img
|
||||
src="/cherrybottom.png"
|
||||
alt="Cherry Bottom"
|
||||
className="w-6 h-6 object-contain -rotate-12 transform hover:rotate-0 transition-transform ease-in-out duration-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-2 transition-all duration-200 whitespace-nowrap group-[.group[data-state=collapsed]]:hidden">
|
||||
<span className="font-bold text-lg">A Cherry On Bottom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Loader2, X, RefreshCw, AlertTriangle, RefreshCcw, Hourglass } from "luc
|
||||
import config from "../../config";
|
||||
import { toast } from "sonner";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
|
||||
interface HistoryRecord {
|
||||
@@ -78,6 +79,75 @@ interface GroupedTableCounts {
|
||||
config: TableCount[];
|
||||
}
|
||||
|
||||
// TableSkeleton component for consistent loading states
|
||||
interface TableSkeletonProps {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
useAccordion?: boolean;
|
||||
}
|
||||
|
||||
const TableSkeleton = ({ rows = 5, columns = 4, useAccordion = false }: TableSkeletonProps) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<TableRow key={rowIndex} className="hover:bg-transparent">
|
||||
<TableCell className="w-full p-0">
|
||||
{useAccordion ? (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value={`skeleton-${rowIndex}`} className="border-0">
|
||||
<AccordionTrigger className="px-4 py-2 cursor-default">
|
||||
<div className="flex justify-between items-center w-full pr-4">
|
||||
<div className="w-[50px]">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="w-[170px]">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="w-[140px]">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="w-[80px]">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="flex justify-between px-4 py-2">
|
||||
<Skeleton className="h-4 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
// Empty state skeleton that maintains consistent sizing
|
||||
const EmptyStateSkeleton = ({ message = "No data available" }: { message?: string }) => {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="text-center py-12">
|
||||
<div className="flex flex-col items-center justify-center min-h-[180px]">
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// Create a component for the empty state of status cards
|
||||
const StatusEmptyState = ({ message }: { message: React.ReactNode }) => (
|
||||
<div className="flex flex-col items-center justify-center py-6 min-h-[150px]">
|
||||
<p className="text-sm text-muted-foreground text-center">{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function DataManagement() {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
@@ -94,6 +164,149 @@ export function DataManagement() {
|
||||
// Add useRef for scroll handling
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check if there's an active script running on component mount
|
||||
const checkActiveProcess = async () => {
|
||||
try {
|
||||
console.log("Checking for active processes...");
|
||||
const response = await fetch(`${config.apiUrl}/csv/status`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to check active process status");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Active process check response:", data);
|
||||
|
||||
if (data.active && data.progress) {
|
||||
console.log("Active process detected:", data.progress);
|
||||
|
||||
// Determine if it's a reset or update based on the progress data
|
||||
const isReset =
|
||||
data.progress.operation?.includes("reset") ||
|
||||
data.progress.operation?.includes("Reset");
|
||||
|
||||
// Set the appropriate state
|
||||
if (isReset) {
|
||||
console.log("Reconnecting to reset process...");
|
||||
setIsResetting(true);
|
||||
// Connect to reset SSE endpoint
|
||||
connectToEventSource("reset");
|
||||
} else {
|
||||
console.log("Reconnecting to update process...");
|
||||
setIsUpdating(true);
|
||||
// Connect to update SSE endpoint
|
||||
connectToEventSource("update");
|
||||
}
|
||||
|
||||
// If we have progress data, initialize the output
|
||||
if (data.progress) {
|
||||
setScriptOutput([JSON.stringify(data.progress)]);
|
||||
}
|
||||
} else {
|
||||
console.log("No active processes detected");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for active processes:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to connect to the appropriate SSE endpoint
|
||||
const connectToEventSource = (type: "update" | "reset") => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// The correct URL structure is /api/csv/{type}/progress
|
||||
const sseUrl = `${config.apiUrl}/csv/${type}/progress`;
|
||||
console.log(`Connecting to event source: ${sseUrl}`);
|
||||
|
||||
// Create the event source with the correct URL pattern
|
||||
const source = new EventSource(
|
||||
sseUrl,
|
||||
{ withCredentials: true }
|
||||
);
|
||||
|
||||
source.onopen = () => {
|
||||
console.log(`SSE connection opened for ${type} progress`);
|
||||
// Add a message indicating reconnection, but don't clear existing output
|
||||
setScriptOutput(prev => [...prev, `[Connected to ${type} progress stream]`]);
|
||||
};
|
||||
|
||||
source.onmessage = (event) => {
|
||||
console.log(`SSE message received:`, event.data);
|
||||
setScriptOutput((prev) => [...prev, event.data]);
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Handle completion events
|
||||
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled') {
|
||||
if (data.operation === 'Full update complete' ||
|
||||
data.operation === 'Full reset complete' ||
|
||||
data.status === 'error' ||
|
||||
data.status === 'cancelled') {
|
||||
source.close();
|
||||
setEventSource(null);
|
||||
setIsUpdating(false);
|
||||
setIsResetting(false);
|
||||
fetchHistory(); // Refresh at end
|
||||
|
||||
if (data.status === 'complete') {
|
||||
toast.success(`${type === 'update' ? 'Update' : 'Reset'} completed successfully`);
|
||||
} else if (data.status === 'error') {
|
||||
toast.error(`${type === 'update' ? 'Update' : 'Reset'} failed: ${data.error || 'Unknown error'}`);
|
||||
} else {
|
||||
toast.warning(`${type === 'update' ? 'Update' : 'Reset'} cancelled`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check progress objects that might be nested
|
||||
if (data.progress && (data.progress.status === 'complete' || data.progress.status === 'error' || data.progress.status === 'cancelled')) {
|
||||
if (data.progress.operation === 'Full update complete' ||
|
||||
data.progress.operation === 'Full reset complete' ||
|
||||
data.progress.status === 'error' ||
|
||||
data.progress.status === 'cancelled') {
|
||||
source.close();
|
||||
setEventSource(null);
|
||||
setIsUpdating(false);
|
||||
setIsResetting(false);
|
||||
fetchHistory(); // Refresh at end
|
||||
|
||||
if (data.progress.status === 'complete') {
|
||||
toast.success(`${type === 'update' ? 'Update' : 'Reset'} completed successfully`);
|
||||
} else if (data.progress.status === 'error') {
|
||||
toast.error(`${type === 'update' ? 'Update' : 'Reset'} failed: ${data.progress.error || 'Unknown error'}`);
|
||||
} else {
|
||||
toast.warning(`${type === 'update' ? 'Update' : 'Reset'} cancelled`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Not JSON, just continue
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = (error) => {
|
||||
console.error(`SSE connection error for ${type}:`, error);
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
source.close();
|
||||
setEventSource(null);
|
||||
|
||||
// Only attempt to reconnect if we're still updating/resetting
|
||||
if ((type === "update" && isUpdating) || (type === "reset" && isResetting)) {
|
||||
console.log("Connection closed, will attempt to reconnect...");
|
||||
setTimeout(() => connectToEventSource(type), 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setEventSource(source);
|
||||
};
|
||||
|
||||
// Helper to format date
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleString();
|
||||
@@ -190,52 +403,9 @@ export function DataManagement() {
|
||||
fetchHistory(); // Refresh at start
|
||||
|
||||
try {
|
||||
const source = new EventSource(`${config.apiUrl}/csv/update/progress`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
source.onmessage = (event) => {
|
||||
setScriptOutput((prev) => [...prev, event.data]);
|
||||
|
||||
// Try to parse for status updates, but don't affect display
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled') {
|
||||
// Only close and reset state if this is the final completion message
|
||||
if (data.operation === 'Full update complete' ||
|
||||
data.status === 'error' ||
|
||||
data.status === 'cancelled') {
|
||||
source.close();
|
||||
setEventSource(null);
|
||||
setIsUpdating(false);
|
||||
fetchHistory(); // Refresh at end
|
||||
|
||||
if (data.status === 'complete') {
|
||||
toast.success("Update completed successfully");
|
||||
} else if (data.status === 'error') {
|
||||
toast.error(`Update failed: ${data.error || 'Unknown error'}`);
|
||||
} else {
|
||||
toast.warning("Update cancelled");
|
||||
}
|
||||
}
|
||||
// For intermediate completions, just show a toast
|
||||
else if (data.status === 'complete') {
|
||||
toast.success(data.message || "Step completed");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Not JSON, just display as is
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = (error) => {
|
||||
setScriptOutput((prev) => [...prev, `[Error] ${error.type}`]);
|
||||
source.close();
|
||||
setEventSource(null);
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
setEventSource(source);
|
||||
console.log("Starting full update...");
|
||||
// Connect to the update SSE endpoint
|
||||
connectToEventSource("update");
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/csv/full-update`, {
|
||||
method: "POST",
|
||||
@@ -246,12 +416,18 @@ export function DataManagement() {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to start update");
|
||||
}
|
||||
|
||||
console.log("Full update request successful");
|
||||
} catch (error) {
|
||||
console.error("Error starting full update:", error);
|
||||
if (error instanceof Error) {
|
||||
toast.error(`Update failed: ${error.message}`);
|
||||
}
|
||||
setIsUpdating(false);
|
||||
setEventSource(null);
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,52 +437,9 @@ export function DataManagement() {
|
||||
fetchHistory(); // Refresh at start
|
||||
|
||||
try {
|
||||
const source = new EventSource(`${config.apiUrl}/csv/reset/progress`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
source.onmessage = (event) => {
|
||||
setScriptOutput((prev) => [...prev, event.data]);
|
||||
|
||||
// Try to parse for status updates, but don't affect display
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled') {
|
||||
// Only close and reset state if this is the final completion message
|
||||
if (data.operation === 'Full reset complete' ||
|
||||
data.status === 'error' ||
|
||||
data.status === 'cancelled') {
|
||||
source.close();
|
||||
setEventSource(null);
|
||||
setIsResetting(false);
|
||||
fetchHistory(); // Refresh at end
|
||||
|
||||
if (data.status === 'complete') {
|
||||
toast.success("Reset completed successfully");
|
||||
} else if (data.status === 'error') {
|
||||
toast.error(`Reset failed: ${data.error || 'Unknown error'}`);
|
||||
} else {
|
||||
toast.warning("Reset cancelled");
|
||||
}
|
||||
}
|
||||
// For intermediate completions, just show a toast
|
||||
else if (data.status === 'complete') {
|
||||
toast.success(data.message || "Step completed");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Not JSON, just display as is
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = (error) => {
|
||||
setScriptOutput((prev) => [...prev, `[Error] ${error.type}`]);
|
||||
source.close();
|
||||
setEventSource(null);
|
||||
setIsResetting(false);
|
||||
};
|
||||
|
||||
setEventSource(source);
|
||||
console.log("Starting full reset...");
|
||||
// Connect to the reset SSE endpoint
|
||||
connectToEventSource("reset");
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/csv/full-reset`, {
|
||||
method: "POST",
|
||||
@@ -317,12 +450,18 @@ export function DataManagement() {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to start reset");
|
||||
}
|
||||
|
||||
console.log("Full reset request successful");
|
||||
} catch (error) {
|
||||
console.error("Error starting full reset:", error);
|
||||
if (error instanceof Error) {
|
||||
toast.error(`Reset failed: ${error.message}`);
|
||||
}
|
||||
setIsResetting(false);
|
||||
setEventSource(null);
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
setEventSource(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -351,6 +490,9 @@ export function DataManagement() {
|
||||
setIsResetting(false);
|
||||
|
||||
toast.info("Operation cancelled");
|
||||
|
||||
// Refresh history data
|
||||
fetchHistory();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to cancel operation: ${
|
||||
@@ -443,14 +585,141 @@ export function DataManagement() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch data immediately on component mount
|
||||
fetchHistory();
|
||||
// Set up async function to run process checks and initial data load
|
||||
const initComponent = async () => {
|
||||
try {
|
||||
console.log("Initializing DataManagement component...");
|
||||
setIsLoading(true);
|
||||
|
||||
// First check for any active operations
|
||||
await checkActiveProcess();
|
||||
|
||||
// Then fetch data (but only if we're not already in an active operation)
|
||||
if (!isUpdating && !isResetting) {
|
||||
await fetchHistory();
|
||||
}
|
||||
|
||||
console.log("Component initialization complete");
|
||||
} catch (error) {
|
||||
console.error("Error during component initialization:", error);
|
||||
// Ensure we fetch data even if checkActiveProcess fails
|
||||
await fetchHistory();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Run the initialization
|
||||
initComponent();
|
||||
|
||||
// Set up periodic refresh every minute
|
||||
const refreshInterval = setInterval(fetchHistory, 60000);
|
||||
|
||||
// Clean up interval and event sources on component unmount
|
||||
return () => {
|
||||
console.log("Cleaning up DataManagement component...");
|
||||
clearInterval(refreshInterval);
|
||||
|
||||
// Close any open event sources
|
||||
if (eventSource) {
|
||||
console.log("Closing event source on unmount");
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clean up interval on component unmount
|
||||
return () => clearInterval(refreshInterval);
|
||||
// Add a dedicated useEffect for direct SSE monitoring that doesn't rely on server status
|
||||
useEffect(() => {
|
||||
// Listen for SSE messages directly, even if the server reports no active processes
|
||||
const setupDirectSSEMonitoring = () => {
|
||||
console.log("Setting up direct SSE monitoring...");
|
||||
|
||||
// Try both update and reset endpoints
|
||||
const setupListenerForType = (type: "update" | "reset") => {
|
||||
const sseUrl = `${config.apiUrl}/csv/${type}/progress`;
|
||||
console.log(`Setting up SSE listener for ${type} at ${sseUrl}`);
|
||||
|
||||
try {
|
||||
const source = new EventSource(sseUrl, { withCredentials: true });
|
||||
|
||||
source.onopen = () => {
|
||||
console.log(`Direct SSE connection opened for ${type}`);
|
||||
};
|
||||
|
||||
source.onmessage = (event) => {
|
||||
console.log(`Direct SSE message from ${type}:`, event.data);
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// If we get a real progress message, update our state accordingly
|
||||
if (data.progress && data.progress.status === 'running') {
|
||||
console.log(`Detected running ${type} process from direct SSE`);
|
||||
|
||||
// Update the appropriate state flag
|
||||
if (type === 'update') {
|
||||
setIsUpdating(true);
|
||||
setIsResetting(false);
|
||||
} else {
|
||||
setIsUpdating(false);
|
||||
setIsResetting(true);
|
||||
}
|
||||
|
||||
// Add the message to our output
|
||||
setScriptOutput(prev => {
|
||||
// Add as first message if no messages, otherwise append
|
||||
if (prev.length === 0) {
|
||||
return [`[Connected to running ${type} process]`, event.data];
|
||||
} else {
|
||||
return [...prev, event.data];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle completion events
|
||||
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled' ||
|
||||
(data.progress && (data.progress.status === 'complete' || data.progress.status === 'error' || data.progress.status === 'cancelled'))) {
|
||||
console.log(`Process ${type} completed or failed`);
|
||||
source.close();
|
||||
setIsUpdating(false);
|
||||
setIsResetting(false);
|
||||
fetchHistory();
|
||||
}
|
||||
} catch (err) {
|
||||
// Not JSON or invalid, just continue
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = (error) => {
|
||||
console.log(`Direct SSE error for ${type}:`, error);
|
||||
source.close();
|
||||
};
|
||||
|
||||
// Return the source for cleanup
|
||||
return source;
|
||||
} catch (err) {
|
||||
console.error(`Error setting up direct SSE for ${type}:`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Set up listeners for both types
|
||||
const updateSource = setupListenerForType('update');
|
||||
const resetSource = setupListenerForType('reset');
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
console.log("Cleaning up direct SSE monitoring");
|
||||
if (updateSource) updateSource.close();
|
||||
if (resetSource) resetSource.close();
|
||||
};
|
||||
};
|
||||
|
||||
// Set up monitoring
|
||||
const cleanup = setupDirectSSEMonitoring();
|
||||
|
||||
// Cleanup on unmount
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
// Add useEffect to handle auto-scrolling
|
||||
@@ -460,11 +729,13 @@ export function DataManagement() {
|
||||
}
|
||||
}, [scriptOutput]);
|
||||
|
||||
// Replace renderTerminal with new version
|
||||
// Replace renderTerminal with new version - simple direct output display
|
||||
const renderTerminal = () => {
|
||||
if (!scriptOutput.length) return null;
|
||||
if (!isUpdating && !isResetting && scriptOutput.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
return (
|
||||
<Card className="col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Script Output</CardTitle>
|
||||
@@ -496,10 +767,19 @@ export function DataManagement() {
|
||||
return new Intl.NumberFormat().format(num);
|
||||
};
|
||||
|
||||
// Update renderTableCountsSection to match other cards' styling
|
||||
// Update renderTableCountsSection to use skeletons
|
||||
const renderTableCountsSection = () => {
|
||||
if (!tableCounts) return null;
|
||||
|
||||
const renderTableCountsSkeleton = () => (
|
||||
<div>
|
||||
{Array.from({ length: 18 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between text-sm items-center py-2 border-b last:border-0">
|
||||
<Skeleton className="h-4 w-[120px]" />
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTableGroup = (_title: string, tables: TableCount[]) => (
|
||||
<div className="mt-0 border-t first:border-t-0 first:mt-0">
|
||||
<div>
|
||||
@@ -526,14 +806,21 @@ export function DataManagement() {
|
||||
<CardTitle>Table Record Counts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && !tableCounts.core.length ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
{isLoading ? (
|
||||
<div className="px-2">
|
||||
{renderTableCountsSkeleton()}
|
||||
</div>
|
||||
) : !tableCounts ? (
|
||||
<StatusEmptyState
|
||||
message={hasError
|
||||
? "Failed to load table data. Please try refreshing."
|
||||
: "No table data available yet. Run a full update or reset."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className="bg-sky-50/50 rounded-t-md px-2">{renderTableGroup('Core Tables', tableCounts.core)}</div>
|
||||
<div className="bg-green-50/50 rounded-b-md px-2">{renderTableGroup('Metrics Tables', tableCounts.metrics)}</div>
|
||||
<div className="bg-sky-50/50 rounded-t-md px-2">{renderTableGroup('Core Tables', tableCounts?.core || [])}</div>
|
||||
<div className="bg-green-50/50 rounded-b-md px-2">{renderTableGroup('Metrics Tables', tableCounts?.metrics || [])}</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -545,91 +832,91 @@ export function DataManagement() {
|
||||
<div className="space-y-8 max-w-4xl">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Full Update Card */}
|
||||
<Card className="relative">
|
||||
<CardHeader className="pb-12">
|
||||
<Card className="relative">
|
||||
<CardHeader className="pb-12">
|
||||
<CardTitle>Full Update</CardTitle>
|
||||
<CardDescription>
|
||||
Import latest data and recalculate all metrics
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4"
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4"
|
||||
onClick={handleFullUpdate}
|
||||
disabled={isUpdating || isResetting}
|
||||
>
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Run Full Update
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isUpdating && (
|
||||
<Button variant="destructive" onClick={handleCancel} className="absolute top-4 right-4">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Full Reset Card */}
|
||||
<Card className="relative">
|
||||
<CardHeader className="pb-12">
|
||||
<Card className="relative">
|
||||
<CardHeader className="pb-12">
|
||||
<CardTitle>Full Reset</CardTitle>
|
||||
<CardDescription>
|
||||
Reset database, reimport all data, and recalculate metrics
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="absolute bottom-4 right-[50%] translate-x-[50%] w-3/4"
|
||||
disabled={isUpdating || isResetting}
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
Full Reset
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
This will completely reset the database, delete all data,
|
||||
and reimport everything from scratch. This action cannot
|
||||
be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleFullReset}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{isResetting && (
|
||||
<Button variant="destructive" onClick={handleCancel} className="absolute top-4 right-4">
|
||||
@@ -641,8 +928,8 @@ export function DataManagement() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Terminal Output */}
|
||||
{(isUpdating || isResetting) && renderTerminal()}
|
||||
{/* Terminal Output - Always show if there are operations running */}
|
||||
{renderTerminal()}
|
||||
|
||||
{/* History Section */}
|
||||
<div className="space-y-4">
|
||||
@@ -673,8 +960,13 @@ export function DataManagement() {
|
||||
<CardContent className="h-[calc(50%)]">
|
||||
<div className="">
|
||||
{isLoading && !tableStatus.length ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
<div>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between text-sm items-center py-2 border-b last:border-0">
|
||||
<Skeleton className="h-4 w-[120px]" />
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : tableStatus.length > 0 ? (
|
||||
tableStatus.map((table) => (
|
||||
@@ -689,13 +981,14 @@ export function DataManagement() {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
{hasError ? (
|
||||
"Failed to load data. Please try refreshing."
|
||||
) : (
|
||||
<>No imports have been performed yet.<br/>Run a full update or reset to import data.</>
|
||||
)}
|
||||
</div>
|
||||
<StatusEmptyState
|
||||
message={hasError
|
||||
? "Failed to load data. Please try refreshing."
|
||||
: (
|
||||
<>No imports have been performed yet.<br/>Run a full update or reset to import data.</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -709,8 +1002,13 @@ export function DataManagement() {
|
||||
<CardContent className="h-[calc(50%)]">
|
||||
<div className="">
|
||||
{isLoading && !moduleStatus.length ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
<div>
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between text-sm items-center py-2 border-b last:border-0">
|
||||
<Skeleton className="h-4 w-[120px]" />
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : moduleStatus.length > 0 ? (
|
||||
moduleStatus.map((module) => (
|
||||
@@ -725,13 +1023,14 @@ export function DataManagement() {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
{hasError ? (
|
||||
"Failed to load data. Please try refreshing."
|
||||
) : (
|
||||
<>No metrics have been calculated yet.<br/>Run a full update or reset to calculate metrics.</>
|
||||
)}
|
||||
</div>
|
||||
<StatusEmptyState
|
||||
message={hasError
|
||||
? "Failed to load data. Please try refreshing."
|
||||
: (
|
||||
<>No metrics have been calculated yet.<br/>Run a full update or reset to calculate metrics.</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -751,14 +1050,7 @@ export function DataManagement() {
|
||||
<Table>
|
||||
<TableBody>
|
||||
{isLoading && !importHistory.length ? (
|
||||
<TableRow>
|
||||
<TableCell className="text-center py-8">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
|
||||
<span className="text-sm text-muted-foreground">Loading import history...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableSkeleton rows={10} columns={4} useAccordion={true} />
|
||||
) : importHistory.length > 0 ? (
|
||||
importHistory.slice(0, 20).map((record) => (
|
||||
<TableRow key={record.id} className="hover:bg-transparent">
|
||||
@@ -838,15 +1130,12 @@ export function DataManagement() {
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="text-center text-sm text-muted-foreground py-4">
|
||||
{hasError ? (
|
||||
"Failed to load import history. Please try refreshing."
|
||||
) : (
|
||||
"No import history available"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<EmptyStateSkeleton
|
||||
message={hasError
|
||||
? "Failed to load import history. Please try refreshing."
|
||||
: "No import history available"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -862,14 +1151,7 @@ export function DataManagement() {
|
||||
<Table>
|
||||
<TableBody>
|
||||
{isLoading && !calculateHistory.length ? (
|
||||
<TableRow>
|
||||
<TableCell className="text-center py-8">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
|
||||
<span className="text-sm text-muted-foreground">Loading calculation history...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableSkeleton rows={9} columns={4} useAccordion={true} />
|
||||
) : calculateHistory.length > 0 ? (
|
||||
calculateHistory.slice(0, 20).map((record) => (
|
||||
<TableRow key={record.id} className="hover:bg-transparent">
|
||||
@@ -954,15 +1236,12 @@ export function DataManagement() {
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="text-center text-sm text-muted-foreground py-4">
|
||||
{hasError ? (
|
||||
"Failed to load calculation history. Please try refreshing."
|
||||
) : (
|
||||
"No calculation history available"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<EmptyStateSkeleton
|
||||
message={hasError
|
||||
? "Failed to load calculation history. Please try refreshing."
|
||||
: "No calculation history available"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -96,17 +96,15 @@
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './App.css'
|
||||
import App from './App.tsx'
|
||||
import { BrowserRouter as Router } from 'react-router-dom'
|
||||
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { useState, useContext } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Box } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export function Login() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { login } = useContext(AuthContext);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
|
||||
|
||||
// Login successful, redirect to the requested page or home
|
||||
const redirectTo = searchParams.get("redirect") || "/";
|
||||
navigate(redirectTo);
|
||||
@@ -36,70 +39,77 @@ export function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
className="min-h-screen bg-gradient-to-b from-slate-100 to-slate-200 dark:from-slate-900 dark:to-slate-800 antialiased"
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-2 bg-primary">
|
||||
<div className="p-4 flex items-center gap-2 group-data-[collapsible=icon]:justify-center text-white">
|
||||
<Box className="h-6 w-6 shrink-0" />
|
||||
<h2 className="text-lg font-semibold group-data-[collapsible=icon]:hidden">
|
||||
Inventory Manager
|
||||
</h2>
|
||||
<motion.div className="flex min-h-svh flex-row items-center justify-center bg-muted p-6 md:p-10">
|
||||
<div className="fixed top-0 w-full backdrop-blur-sm bg-white/40 border-b shadow-sm z-10">
|
||||
<div className="mx-auto p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2 font-medium text-3xl justify-center sm:justify-start">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 "></div>
|
||||
<img
|
||||
src="/cherrybottom.png"
|
||||
alt="Cherry Bottom"
|
||||
className="h-12 w-12 object-contain -rotate-12 transform hover:rotate-0 transition-transform ease-in-out duration-300 relative z-10"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-bold font-text-primary">A Cherry On Bottom</span>
|
||||
</div>
|
||||
<p className="text-sm italic text-muted-foreground text-center sm:text-left ml-32 -mt-1">
|
||||
supporting the cherry on top
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
className="container relative flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center"
|
||||
>
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<Card className="border-none shadow-xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<Box className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">
|
||||
Log in to continue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit" disabled={isLoading}>
|
||||
{isLoading && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="w-full sm:w-[80%] max-w-sm mt-20">
|
||||
<LoginForm onSubmit={handleSubmit} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginFormProps {
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
function LoginForm({ className, isLoading, onSubmit, ...props }: LoginFormProps) {
|
||||
return (
|
||||
<motion.div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden rounded-lg shadow-lg">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-2xl font-bold text-center">Log in to your account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid p-0 h-full">
|
||||
<form className="p-6 md:p-8 flex flex-col gap-6" onSubmit={onSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Logging in..." : "Log In"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export default {
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
|
||||
Reference in New Issue
Block a user