diff --git a/inventory/index.html b/inventory/index.html index c69b332..5ba2347 100644 --- a/inventory/index.html +++ b/inventory/index.html @@ -2,9 +2,12 @@ - + + + + - Inventory Manager + A Cherry On Bottom
diff --git a/inventory/public/box.svg b/inventory/public/box.svg deleted file mode 100644 index bf13e8b..0000000 --- a/inventory/public/box.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/inventory/public/cherrybottom.ico b/inventory/public/cherrybottom.ico new file mode 100644 index 0000000..318a129 Binary files /dev/null and b/inventory/public/cherrybottom.ico differ diff --git a/inventory/public/cherrybottom.png b/inventory/public/cherrybottom.png new file mode 100644 index 0000000..9c35b01 Binary files /dev/null and b/inventory/public/cherrybottom.png differ diff --git a/inventory/src/App.css b/inventory/src/App.css index 8da90e7..b8c8a47 100644 --- a/inventory/src/App.css +++ b/inventory/src/App.css @@ -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; +} \ No newline at end of file diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 95fe654..a53783a 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -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 ( -
- -

- Inventory Manager -

+
+
+
+ Cherry Bottom +
+
+ A Cherry On Bottom +
+
diff --git a/inventory/src/components/settings/DataManagement.tsx b/inventory/src/components/settings/DataManagement.tsx index 5c5c5bd..07797c3 100644 --- a/inventory/src/components/settings/DataManagement.tsx +++ b/inventory/src/components/settings/DataManagement.tsx @@ -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 ( + + + {Array.from({ length: rows }).map((_, rowIndex) => ( + + + {useAccordion ? ( + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ ) : ( +
+ + +
+ )} +
+
+ ))} +
+
+ ); +}; + +// Empty state skeleton that maintains consistent sizing +const EmptyStateSkeleton = ({ message = "No data available" }: { message?: string }) => { + return ( + + +
+

{message}

+
+
+
+ ); +}; + +// Create a component for the empty state of status cards +const StatusEmptyState = ({ message }: { message: React.ReactNode }) => ( +
+

{message}

+
+); + 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(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 ( Script Output @@ -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 = () => ( +
+ {Array.from({ length: 18 }).map((_, i) => ( +
+ + +
+ ))} +
+ ); + const renderTableGroup = (_title: string, tables: TableCount[]) => (
@@ -526,14 +806,21 @@ export function DataManagement() { Table Record Counts - {isLoading && !tableCounts.core.length ? ( -
- + {isLoading ? ( +
+ {renderTableCountsSkeleton()}
+ ) : !tableCounts ? ( + ) : (
-
{renderTableGroup('Core Tables', tableCounts.core)}
-
{renderTableGroup('Metrics Tables', tableCounts.metrics)}
+
{renderTableGroup('Core Tables', tableCounts?.core || [])}
+
{renderTableGroup('Metrics Tables', tableCounts?.metrics || [])}
)} @@ -545,91 +832,91 @@ export function DataManagement() {
{/* Full Update Card */} - - + + Full Update Import latest data and recalculate all metrics - - -
- + + )} + {isUpdating && ( - )} -
-
-
+ + + )} +
+ + {/* Full Reset Card */} - - + + Full Reset Reset database, reimport all data, and recalculate metrics - +
- - - - - - + )} + + + + Are you absolutely sure? - + This will completely reset the database, delete all data, and reimport everything from scratch. This action cannot be undone. - - - - Cancel + + + + Cancel Continue - - - + + + {isResetting && (
- {/* Terminal Output */} - {(isUpdating || isResetting) && renderTerminal()} + {/* Terminal Output - Always show if there are operations running */} + {renderTerminal()} {/* History Section */}
@@ -673,8 +960,13 @@ export function DataManagement() {
{isLoading && !tableStatus.length ? ( -
- +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))}
) : tableStatus.length > 0 ? ( tableStatus.map((table) => ( @@ -689,13 +981,14 @@ export function DataManagement() {
)) ) : ( -
- {hasError ? ( - "Failed to load data. Please try refreshing." - ) : ( - <>No imports have been performed yet.
Run a full update or reset to import data. - )} -
+ No imports have been performed yet.
Run a full update or reset to import data. + ) + } + /> )}
@@ -709,8 +1002,13 @@ export function DataManagement() {
{isLoading && !moduleStatus.length ? ( -
- +
+ {Array.from({ length: 7 }).map((_, i) => ( +
+ + +
+ ))}
) : moduleStatus.length > 0 ? ( moduleStatus.map((module) => ( @@ -725,13 +1023,14 @@ export function DataManagement() {
)) ) : ( -
- {hasError ? ( - "Failed to load data. Please try refreshing." - ) : ( - <>No metrics have been calculated yet.
Run a full update or reset to calculate metrics. - )} -
+ No metrics have been calculated yet.
Run a full update or reset to calculate metrics. + ) + } + /> )}
@@ -751,14 +1050,7 @@ export function DataManagement() { {isLoading && !importHistory.length ? ( - - -
- - Loading import history... -
-
-
+ ) : importHistory.length > 0 ? ( importHistory.slice(0, 20).map((record) => ( @@ -838,15 +1130,12 @@ export function DataManagement() { )) ) : ( - - - {hasError ? ( - "Failed to load import history. Please try refreshing." - ) : ( - "No import history available" - )} - - + )}
@@ -862,14 +1151,7 @@ export function DataManagement() { {isLoading && !calculateHistory.length ? ( - - -
- - Loading calculation history... -
-
-
+ ) : calculateHistory.length > 0 ? ( calculateHistory.slice(0, 20).map((record) => ( @@ -954,15 +1236,12 @@ export function DataManagement() { )) ) : ( - - - {hasError ? ( - "Failed to load calculation history. Please try refreshing." - ) : ( - "No calculation history available" - )} - - + )}
diff --git a/inventory/src/index.css b/inventory/src/index.css index a2fa817..150883b 100644 --- a/inventory/src/index.css +++ b/inventory/src/index.css @@ -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; } } diff --git a/inventory/src/main.tsx b/inventory/src/main.tsx index 69ddf4f..b7df181 100644 --- a/inventory/src/main.tsx +++ b/inventory/src/main.tsx @@ -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' diff --git a/inventory/src/pages/Login.tsx b/inventory/src/pages/Login.tsx index 665037f..d69296f 100644 --- a/inventory/src/pages/Login.tsx +++ b/inventory/src/pages/Login.tsx @@ -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) => { 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 ( - -
-
- -

- Inventory Manager -

+ +
+
+
+
+
+ Cherry Bottom +
+ A Cherry On Bottom +
+

+ supporting the cherry on top +

- -
- - -
- -
- - Log in to continue - -
- -
-
-
- setUsername(e.target.value)} - disabled={isLoading} - className="w-full" - /> -
-
- setPassword(e.target.value)} - disabled={isLoading} - className="w-full" - /> -
- -
-
-
-
-
-
+
+ +
+ +
+ ); +} + +interface LoginFormProps { + className?: string; + isLoading?: boolean; + onSubmit: (e: React.FormEvent) => void; +} + +function LoginForm({ className, isLoading, onSubmit, ...props }: LoginFormProps) { + return ( + + + + Log in to your account + + +
+
+ + +
+ +
+ + +
+ + +
+ +
+
); } diff --git a/inventory/tailwind.config.js b/inventory/tailwind.config.js index c04f2f5..0e14d50 100644 --- a/inventory/tailwind.config.js +++ b/inventory/tailwind.config.js @@ -14,6 +14,9 @@ export default { } }, extend: { + fontFamily: { + sans: ['Inter', 'sans-serif'], + }, colors: { border: 'hsl(var(--border))', input: 'hsl(var(--input))',