mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
Merge pull request #22 from jhbruhn/feature/shadcn
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
Feature: shadcn ui
This commit is contained in:
commit
47c36f92dd
34 changed files with 6376 additions and 1635 deletions
|
|
@ -8,5 +8,8 @@
|
|||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"shadcn"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/App.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "src/components",
|
||||
"utils": "src/lib/utils",
|
||||
"ui": "src/components/ui",
|
||||
"lib": "src/lib",
|
||||
"hooks": "src/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
4060
package-lock.json
generated
4060
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -23,16 +23,28 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^10.0.0",
|
||||
"konva": "^10.0.12",
|
||||
"lucide-react": "^0.562.0",
|
||||
"pyodide": "^0.29.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-konva": "^19.2.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"update-electron-app": "^3.1.2",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
|
|
@ -62,6 +74,7 @@
|
|||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "3.7.4",
|
||||
"shadcn": "^3.6.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
|
|
|
|||
132
src/App.css
132
src/App.css
|
|
@ -1,4 +1,108 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* ============================================
|
||||
SHADCN/UI THEME VARIABLES
|
||||
CSS variables for shadcn/ui components
|
||||
============================================ */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Background colors */
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
/* Card colors */
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
/* Popover colors */
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
/* Primary - Blue (existing brand color) */
|
||||
--primary: hsl(221.2 83.2% 53.3%); /* blue-600 */
|
||||
--primary-foreground: hsl(210 40% 98%);
|
||||
|
||||
/* Secondary - Orange (existing secondary color) */
|
||||
--secondary: hsl(24.6 95% 53.1%); /* orange-500 */
|
||||
--secondary-foreground: hsl(60 9.1% 97.8%);
|
||||
|
||||
/* Muted colors */
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
|
||||
/* Accent - Purple (existing accent color) */
|
||||
--accent: hsl(262.1 83.3% 57.8%); /* purple-500 */
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
|
||||
/* Destructive - Red (existing danger color) */
|
||||
--destructive: hsl(0 84.2% 60.2%); /* red-500 */
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
|
||||
/* Success - Green (existing success color) */
|
||||
--success: hsl(142.1 76.2% 36.3%); /* green-600 */
|
||||
--success-foreground: hsl(210 40% 98%);
|
||||
|
||||
/* Warning - Amber (existing warning color) */
|
||||
--warning: hsl(45.4 93.4% 47.5%); /* amber-500 */
|
||||
--warning-foreground: hsl(60 9.1% 97.8%);
|
||||
|
||||
/* Info - Cyan (existing info color) */
|
||||
--info: hsl(188.7 85.7% 53.3%); /* cyan-500 */
|
||||
--info-foreground: hsl(210 40% 98%);
|
||||
|
||||
/* Border and input */
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--ring: hsl(221.2 83.2% 53.3%); /* matches primary */
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: hsl(222.2 84% 4.9%);
|
||||
|
||||
--foreground: hsl(210 40% 98%);
|
||||
|
||||
--card: oklch(27.8% 0.033 256.848);
|
||||
--card-foreground: hsl(210 40% 98%);
|
||||
|
||||
--popover: hsl(222.2 84% 4.9%);
|
||||
--popover-foreground: hsl(210 40% 98%);
|
||||
|
||||
--primary: hsl(217.2 91.2% 59.8%); /* blue-500 lighter for dark */
|
||||
--primary-foreground: hsl(210 40% 98%);
|
||||
|
||||
--secondary: hsl(20.5 90.2% 48.2%); /* orange-600 for dark */
|
||||
--secondary-foreground: hsl(210 40% 98%);
|
||||
|
||||
--muted: hsl(217.2 32.6% 17.5%);
|
||||
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||
|
||||
--accent: hsl(263.4 70% 50.4%); /* purple-600 for dark */
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
|
||||
--destructive: hsl(0 62.8% 30.6%); /* red-900 */
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
|
||||
--success: hsl(142.1 70.6% 45.3%); /* green-500 for dark */
|
||||
--success-foreground: hsl(210 40% 98%);
|
||||
|
||||
--warning: hsl(47.9 95.8% 53.1%); /* amber-400 for dark */
|
||||
--warning-foreground: hsl(26 83.3% 14.1%);
|
||||
|
||||
--info: hsl(188.7 85.7% 53.3%); /* cyan-500 */
|
||||
--info-foreground: hsl(210 40% 98%);
|
||||
|
||||
--border: hsl(217.2 32.6% 37.5%);
|
||||
--input: hsl(217.2 32.6% 47.5%);
|
||||
--ring: hsl(224.3 76.3% 48%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
THEME DEFINITION - Tailwind v4
|
||||
|
|
@ -6,7 +110,28 @@
|
|||
============================================ */
|
||||
|
||||
@theme {
|
||||
/* PRIMARY - Main brand color (references Blue) */
|
||||
/* SHADCN/UI COLORS - For bg-primary, bg-destructive, etc. */
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* PRIMARY - Main brand color (references Blue) - For bg-primary-600 style classes */
|
||||
--color-primary-50: var(--color-blue-50);
|
||||
--color-primary-100: var(--color-blue-100);
|
||||
--color-primary-200: var(--color-blue-200);
|
||||
|
|
@ -104,7 +229,7 @@
|
|||
/* Canvas/Konva-specific colors for embroidery rendering */
|
||||
--color-canvas-grid: #e0e0e0;
|
||||
--color-canvas-origin: #888888;
|
||||
--color-canvas-hoop: #2196F3;
|
||||
--color-canvas-hoop: #2196f3;
|
||||
--color-canvas-bounds: #ff0000;
|
||||
--color-canvas-position: #ff0000;
|
||||
}
|
||||
|
|
@ -169,7 +294,8 @@
|
|||
|
||||
/* Pulse glow effect - uses primary-600 */
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgb(37 99 235 / 0.4); /* primary-600 with 40% opacity */
|
||||
}
|
||||
50% {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useUIStore } from "./stores/useUIStore";
|
|||
import { AppHeader } from "./components/AppHeader";
|
||||
import { LeftSidebar } from "./components/LeftSidebar";
|
||||
import { PatternCanvas } from "./components/PatternCanvas";
|
||||
import { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder";
|
||||
import { PatternCanvasPlaceholder } from "./components/PatternCanvasPlaceholder";
|
||||
import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker";
|
||||
import "./App.css";
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ function App() {
|
|||
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-300 dark:bg-gray-900 overflow-hidden">
|
||||
<div className="h-screen flex flex-col bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||
<AppHeader />
|
||||
|
||||
<div className="flex-1 p-4 sm:p-5 lg:p-6 w-full overflow-y-auto lg:overflow-hidden flex flex-col">
|
||||
|
|
@ -76,7 +76,7 @@ function App() {
|
|||
|
||||
{/* Right Column - Pattern Preview */}
|
||||
<div className="flex flex-col lg:overflow-hidden lg:h-full">
|
||||
{pesData ? <PatternCanvas /> : <PatternPreviewPlaceholder />}
|
||||
{pesData ? <PatternCanvas /> : <PatternCanvasPlaceholder />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore } from "../stores/useMachineStore";
|
||||
import { useUIStore } from "../stores/useUIStore";
|
||||
import { WorkflowStepper } from "./WorkflowStepper";
|
||||
import { ErrorPopover } from "./ErrorPopover";
|
||||
import { ErrorPopoverContent } from "./ErrorPopover";
|
||||
import { getStateVisualInfo } from "../utils/machineStateHelpers";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
|
|
@ -13,6 +12,16 @@ import {
|
|||
ArrowPathIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AppHeader() {
|
||||
const {
|
||||
|
|
@ -39,17 +48,12 @@ export function AppHeader() {
|
|||
})),
|
||||
);
|
||||
|
||||
const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore(
|
||||
const { pyodideError } = useUIStore(
|
||||
useShallow((state) => ({
|
||||
pyodideError: state.pyodideError,
|
||||
showErrorPopover: state.showErrorPopover,
|
||||
setErrorPopover: state.setErrorPopover,
|
||||
})),
|
||||
);
|
||||
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const errorButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Get state visual info for header status badge
|
||||
const stateVisual = getStateVisualInfo(machineStatus);
|
||||
const stateIcons = {
|
||||
|
|
@ -62,27 +66,8 @@ export function AppHeader() {
|
|||
};
|
||||
const StatusIcon = stateIcons[stateVisual.iconName];
|
||||
|
||||
// Close error popover when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
errorPopoverRef.current &&
|
||||
!errorPopoverRef.current.contains(event.target as Node) &&
|
||||
errorButtonRef.current &&
|
||||
!errorButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setErrorPopover(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showErrorPopover) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [showErrorPopover, setErrorPopover]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
|
||||
{/* Machine Connection Status - Responsive width column */}
|
||||
|
|
@ -101,64 +86,95 @@ export function AppHeader() {
|
|||
Respira
|
||||
</h1>
|
||||
{isConnected && machineInfo?.serialNumber && (
|
||||
<span
|
||||
className="text-xs text-primary-200 cursor-help"
|
||||
title={`Serial: ${machineInfo.serialNumber}${
|
||||
machineInfo.macAddress
|
||||
? `\nMAC: ${machineInfo.macAddress}`
|
||||
: ""
|
||||
}${
|
||||
machineInfo.totalCount !== undefined
|
||||
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
|
||||
: ""
|
||||
}${
|
||||
machineInfo.serviceCount !== undefined
|
||||
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs text-primary-200 cursor-help">
|
||||
• {machineInfo.serialNumber}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="font-semibold">
|
||||
Serial: {machineInfo.serialNumber}
|
||||
</p>
|
||||
{machineInfo.macAddress && (
|
||||
<p className="text-xs">
|
||||
MAC: {machineInfo.macAddress}
|
||||
</p>
|
||||
)}
|
||||
{machineInfo.totalCount !== undefined && (
|
||||
<p className="text-xs">
|
||||
Total stitches:{" "}
|
||||
{machineInfo.totalCount.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
{machineInfo.serviceCount !== undefined && (
|
||||
<p className="text-xs">
|
||||
Stitches since service:{" "}
|
||||
{machineInfo.serviceCount.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPolling && (
|
||||
<ArrowPathIcon
|
||||
className="w-3.5 h-3.5 text-primary-200 animate-spin"
|
||||
title="Auto-refreshing status"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">Auto-refreshing machine status</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
onClick={disconnect}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-white/10 hover:bg-danger-600 text-primary-100 hover:text-white border border-white/20 hover:border-danger-600 cursor-pointer transition-all flex-shrink-0"
|
||||
title="Disconnect from machine"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 bg-white/10 hover:bg-danger-600 text-primary-100 hover:text-white border-white/20 hover:border-danger-600 flex-shrink-0"
|
||||
aria-label="Disconnect from machine"
|
||||
>
|
||||
<XMarkIcon className="w-3 h-3" />
|
||||
Disconnect
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0">
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1.5 px-2.5 py-1.5 sm:py-1 text-sm font-semibold bg-white/20 text-white border-white/30 flex-shrink-0 cursor-help"
|
||||
>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{machineStatusName}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">{stateVisual.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-primary-200">Not Connected</p>
|
||||
)}
|
||||
|
||||
{/* Error indicator - always render to prevent layout shift */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={errorButtonRef}
|
||||
onClick={() => setErrorPopover(!showErrorPopover)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className={cn(
|
||||
"gap-1.5 flex-shrink-0",
|
||||
machineErrorMessage || pyodideError
|
||||
? "cursor-pointer animate-pulse hover:animate-none"
|
||||
: "invisible pointer-events-none"
|
||||
}`}
|
||||
title="Click to view error details"
|
||||
? "animate-pulse hover:animate-none"
|
||||
: "invisible pointer-events-none",
|
||||
)}
|
||||
aria-label="View error details"
|
||||
disabled={!(machineErrorMessage || pyodideError)}
|
||||
>
|
||||
|
|
@ -191,19 +207,21 @@ export function AppHeader() {
|
|||
return "Error";
|
||||
})()}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Error popover */}
|
||||
{showErrorPopover && (machineErrorMessage || pyodideError) && (
|
||||
<ErrorPopover
|
||||
ref={errorPopoverRef}
|
||||
machineError={machineError}
|
||||
{/* Error popover content */}
|
||||
{(machineErrorMessage || pyodideError) && (
|
||||
<ErrorPopoverContent
|
||||
machineError={
|
||||
machineError != 0xdd ? machineError : undefined
|
||||
}
|
||||
isPairingError={isPairingError}
|
||||
errorMessage={machineErrorMessage}
|
||||
pyodideError={pyodideError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -214,5 +232,6 @@ export function AppHeader() {
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { BluetoothDevice } from "../types/electron";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function BluetoothDevicePicker() {
|
||||
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
|
||||
|
|
@ -40,48 +49,17 @@ export function BluetoothDevicePicker() {
|
|||
setIsScanning(false);
|
||||
}, []);
|
||||
|
||||
// Handle escape key
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleCancel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [isOpen, handleEscape]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
|
||||
onClick={handleCancel}
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent
|
||||
className="border-t-4 border-primary-600 dark:border-primary-500"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="bluetooth-picker-title"
|
||||
aria-describedby="bluetooth-picker-message"
|
||||
>
|
||||
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
|
||||
<h3
|
||||
id="bluetooth-picker-title"
|
||||
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
|
||||
>
|
||||
Select Bluetooth Device
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Bluetooth Device</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isScanning && devices.length === 0 ? (
|
||||
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -95,56 +73,47 @@ export function BluetoothDevicePicker() {
|
|||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
/>
|
||||
</svg>
|
||||
<span id="bluetooth-picker-message">
|
||||
Scanning for Bluetooth devices...
|
||||
</span>
|
||||
<span>Scanning for Bluetooth devices...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p
|
||||
id="bluetooth-picker-message"
|
||||
className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{devices.length} device{devices.length !== 1 ? "s" : ""} found.
|
||||
Select a device to connect:
|
||||
</p>
|
||||
`${devices.length} device${devices.length !== 1 ? "s" : ""} found. Select a device to connect:`
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!isScanning && devices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<button
|
||||
<Button
|
||||
key={device.deviceId}
|
||||
onClick={() => handleSelectDevice(device.deviceId)}
|
||||
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
aria-label={`Connect to ${device.deviceName}`}
|
||||
variant="outline"
|
||||
className="w-full h-auto px-4 py-3 justify-start"
|
||||
>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{device.deviceName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">{device.deviceName}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{device.deviceId}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
aria-label="Cancel device selection"
|
||||
>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import { useEffect, useCallback } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -21,75 +31,32 @@ export function ConfirmDialog({
|
|||
onCancel,
|
||||
variant = "warning",
|
||||
}: ConfirmDialogProps) {
|
||||
// Handle escape key
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [isOpen, handleEscape]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === "danger" ? "border-t-4 border-danger-600 dark:border-danger-500" : "border-t-4 border-warning-500 dark:border-warning-600"}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="dialog-title"
|
||||
aria-describedby="dialog-message"
|
||||
>
|
||||
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
|
||||
<h3
|
||||
id="dialog-title"
|
||||
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p
|
||||
id="dialog-message"
|
||||
className="m-0 leading-relaxed text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
autoFocus
|
||||
aria-label="Cancel action"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={
|
||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<AlertDialogContent
|
||||
className={cn(
|
||||
variant === "danger"
|
||||
? "px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
: "px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
}
|
||||
aria-label={`Confirm: ${confirmText}`}
|
||||
? "border-t-4 border-danger-600 dark:border-danger-500"
|
||||
: "border-t-4 border-warning-500 dark:border-warning-600",
|
||||
)}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{message}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className={cn(
|
||||
variant === "danger" &&
|
||||
"bg-danger-600 hover:bg-danger-700 dark:bg-danger-700 dark:hover:bg-danger-600",
|
||||
)}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { useShallow } from "zustand/react/shallow";
|
|||
import { useMachineStore } from "../stores/useMachineStore";
|
||||
import { isBluetoothSupported } from "../utils/bluetoothSupport";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export function ConnectionPrompt() {
|
||||
const { connect } = useMachineStore(
|
||||
|
|
@ -12,7 +15,8 @@ export function ConnectionPrompt() {
|
|||
|
||||
if (isBluetoothSupported()) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
|
||||
<Card className="p-0 gap-0 border-l-4 border-gray-400 dark:border-gray-600">
|
||||
<CardContent className="p-4 rounded-lg">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
|
||||
<svg
|
||||
|
|
@ -38,28 +42,27 @@ export function ConnectionPrompt() {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={connect}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer"
|
||||
>
|
||||
<Button onClick={connect} className="w-full">
|
||||
Connect to Machine
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-warning-50 dark:bg-warning-900/20 p-4 rounded-lg shadow-md border-l-4 border-warning-500 dark:border-warning-600">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<Alert className="bg-warning-50 dark:bg-warning-900/20 border-l-4 border-warning-500 dark:border-warning-600">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-warning-600 dark:text-warning-400" />
|
||||
<AlertDescription className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">
|
||||
Browser Not Supported
|
||||
</h3>
|
||||
<p className="text-sm text-warning-800 dark:text-warning-200 mb-3">
|
||||
<p className="text-sm text-warning-800 dark:text-warning-200">
|
||||
Your browser doesn't support Web Bluetooth, which is required to
|
||||
connect to your embroidery machine.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100">
|
||||
Please try one of these options:
|
||||
|
|
@ -79,8 +82,7 @@ export function ConnectionPrompt() {
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
import { forwardRef } from "react";
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { getErrorDetails } from "../utils/errorCodeHelpers";
|
||||
import { PopoverContent } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ErrorPopoverProps {
|
||||
interface ErrorPopoverContentProps {
|
||||
machineError?: number;
|
||||
isPairingError: boolean;
|
||||
errorMessage?: string | null;
|
||||
pyodideError?: string | null;
|
||||
}
|
||||
|
||||
export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
||||
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
|
||||
export function ErrorPopoverContent({
|
||||
machineError,
|
||||
isPairingError,
|
||||
errorMessage,
|
||||
pyodideError,
|
||||
}: ErrorPopoverContentProps) {
|
||||
const errorDetails = getErrorDetails(machineError);
|
||||
const isPairingErr = isPairingError;
|
||||
const errorMsg = pyodideError || errorMessage || "";
|
||||
|
|
@ -44,31 +49,29 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
|||
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
|
||||
role="dialog"
|
||||
aria-label="Error details"
|
||||
>
|
||||
<div
|
||||
className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
|
||||
<PopoverContent
|
||||
className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)}
|
||||
align="start"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
<Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-base font-semibold ${textColor} mb-2`}>
|
||||
<h3 className={cn("text-base font-semibold mb-2", textColor)}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={`text-sm ${descColor} mb-3`}>
|
||||
<p className={cn("text-sm mb-3", descColor)}>
|
||||
{errorDetails?.description || errorMsg}
|
||||
</p>
|
||||
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
||||
<>
|
||||
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
|
||||
<h4 className={cn("text-sm font-semibold mb-2", textColor)}>
|
||||
{isInfo ? "Steps:" : "How to Fix:"}
|
||||
</h4>
|
||||
<ol
|
||||
className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}
|
||||
className={cn(
|
||||
"list-decimal list-inside text-sm space-y-1.5",
|
||||
listColor,
|
||||
)}
|
||||
>
|
||||
{errorDetails.solutions.map((solution, index) => (
|
||||
<li key={index} className="pl-2">
|
||||
|
|
@ -79,17 +82,13 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
|||
</>
|
||||
)}
|
||||
{machineError !== undefined && !errorDetails?.isInformational && (
|
||||
<p className={`text-xs ${descColor} mt-3 font-mono`}>
|
||||
<p className={cn("text-xs mt-3 font-mono", descColor)}>
|
||||
Error Code: 0x
|
||||
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ErrorPopover.displayName = "ErrorPopover";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ import {
|
|||
} from "@heroicons/react/24/solid";
|
||||
import { createFileService } from "../platform";
|
||||
import type { IFileService } from "../platform/interfaces/IFileService";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FileUpload() {
|
||||
// Machine store
|
||||
|
|
@ -202,12 +208,11 @@ export function FileUpload() {
|
|||
: "text-gray-600 dark:text-gray-400";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}
|
||||
>
|
||||
<Card className={cn("p-0 gap-0 border-l-4", borderColor)}>
|
||||
<CardContent className="p-4 rounded-lg">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<DocumentTextIcon
|
||||
className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`}
|
||||
className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
|
|
@ -253,40 +258,41 @@ export function FileUpload() {
|
|||
className="hidden"
|
||||
disabled={isLoading || patternUploaded || isUploading}
|
||||
/>
|
||||
<label
|
||||
htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
|
||||
<Button
|
||||
asChild={!fileService.hasNativeDialogs()}
|
||||
onClick={
|
||||
fileService.hasNativeDialogs()
|
||||
? () => handleFileChange()
|
||||
: undefined
|
||||
}
|
||||
className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${
|
||||
isLoading || patternUploaded || isUploading
|
||||
? "opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white"
|
||||
: "cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
disabled={isLoading || patternUploaded || isUploading}
|
||||
variant="outline"
|
||||
className="flex-[2]"
|
||||
>
|
||||
{fileService.hasNativeDialogs() ? (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : patternUploaded ? (
|
||||
<>
|
||||
<CheckCircleIcon className="w-3.5 h-3.5" />
|
||||
<span>Locked</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FolderOpenIcon className="w-3.5 h-3.5" />
|
||||
<span>Choose PES File</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<label htmlFor="file-input" className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : patternUploaded ? (
|
||||
|
|
@ -301,15 +307,17 @@ export function FileUpload() {
|
|||
</>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{pesData &&
|
||||
canUploadPattern(machineStatus) &&
|
||||
!patternUploaded &&
|
||||
uploadProgress < 100 && (
|
||||
<button
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!isConnected || isUploading || !boundsCheck.fits}
|
||||
className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1"
|
||||
aria-label={
|
||||
isUploading
|
||||
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
|
||||
|
|
@ -318,36 +326,18 @@ export function FileUpload() {
|
|||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 animate-spin inline mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{uploadProgress > 0
|
||||
? uploadProgress.toFixed(0) + "%"
|
||||
: "Uploading"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
|
||||
<ArrowUpTrayIcon className="w-3.5 h-3.5" />
|
||||
Upload
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -364,12 +354,7 @@ export function FileUpload() {
|
|||
{pyodideProgress.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary-500 via-primary-600 to-primary-700 dark:from-primary-600 dark:via-primary-700 dark:to-primary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
|
||||
style={{ width: `${pyodideProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Progress value={pyodideProgress} className="h-2.5" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
|
||||
{isLoading && !pyodideReady
|
||||
? "File dialog will open automatically when ready"
|
||||
|
|
@ -393,15 +378,22 @@ export function FileUpload() {
|
|||
}}
|
||||
>
|
||||
{pesData && !canUploadPattern(machineStatus) && (
|
||||
<div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm">
|
||||
<Alert className="bg-warning-100 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<AlertDescription className="text-warning-800 dark:text-warning-200 text-sm">
|
||||
Cannot upload while {getMachineStateCategory(machineStatus)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pesData && boundsCheck.error && (
|
||||
<div className="bg-danger-100 dark:bg-danger-900/20 text-danger-800 dark:text-danger-200 px-3 py-2 rounded border border-danger-200 dark:border-danger-800 text-sm">
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="bg-danger-100 dark:bg-danger-900/20 border-danger-200 dark:border-danger-800"
|
||||
>
|
||||
<AlertDescription className="text-danger-800 dark:text-danger-200 text-sm">
|
||||
<strong>Pattern too large:</strong> {boundsCheck.error}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -417,14 +409,13 @@ export function FileUpload() {
|
|||
: "Starting..."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-secondary-500 via-secondary-600 to-secondary-700 dark:from-secondary-600 dark:via-secondary-700 dark:to-secondary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
<Progress
|
||||
value={uploadProgress}
|
||||
className="h-2.5 [&>div]:bg-gradient-to-r [&>div]:from-secondary-500 [&>div]:via-secondary-600 [&>div]:to-secondary-700 dark:[&>div]:from-secondary-600 dark:[&>div]:via-secondary-700 dark:[&>div]:to-secondary-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,224 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
InformationCircleIcon,
|
||||
CheckCircleIcon,
|
||||
BoltIcon,
|
||||
PauseCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
WifiIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import type { MachineInfo } from "../types/machine";
|
||||
import { MachineStatus } from "../types/machine";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import {
|
||||
shouldConfirmDisconnect,
|
||||
getStateVisualInfo,
|
||||
} from "../utils/machineStateHelpers";
|
||||
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
|
||||
|
||||
interface MachineConnectionProps {
|
||||
isConnected: boolean;
|
||||
machineInfo: MachineInfo | null;
|
||||
machineStatus: MachineStatus;
|
||||
machineStatusName: string;
|
||||
machineError: number;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function MachineConnection({
|
||||
isConnected,
|
||||
machineInfo,
|
||||
machineStatus,
|
||||
machineStatusName,
|
||||
machineError,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}: MachineConnectionProps) {
|
||||
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
||||
|
||||
const handleDisconnectClick = () => {
|
||||
if (shouldConfirmDisconnect(machineStatus)) {
|
||||
setShowDisconnectConfirm(true);
|
||||
} else {
|
||||
onDisconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDisconnect = () => {
|
||||
setShowDisconnectConfirm(false);
|
||||
onDisconnect();
|
||||
};
|
||||
|
||||
const stateVisual = getStateVisualInfo(machineStatus);
|
||||
|
||||
// Map icon names to Heroicons
|
||||
const stateIcons = {
|
||||
ready: CheckCircleIcon,
|
||||
active: BoltIcon,
|
||||
waiting: PauseCircleIcon,
|
||||
complete: CheckCircleIcon,
|
||||
interrupted: PauseCircleIcon,
|
||||
error: ExclamationTriangleIcon,
|
||||
};
|
||||
|
||||
const statusBadgeColors = {
|
||||
idle: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
|
||||
info: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
|
||||
active:
|
||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
||||
waiting:
|
||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
||||
warning:
|
||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
||||
complete:
|
||||
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
|
||||
success:
|
||||
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
|
||||
interrupted:
|
||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
||||
error:
|
||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
||||
danger:
|
||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
||||
};
|
||||
|
||||
// Only show error info when connected AND there's an actual error
|
||||
const errorInfo =
|
||||
isConnected && hasError(machineError)
|
||||
? getErrorDetails(machineError)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isConnected ? (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Machine
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Ready to connect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onConnect}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer"
|
||||
>
|
||||
Connect to Machine
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-success-600 dark:border-success-500">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Machine Info
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{machineInfo?.modelNumber || "Brother Embroidery Machine"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error/Info Display */}
|
||||
{errorInfo &&
|
||||
(errorInfo.isInformational ? (
|
||||
<div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-info-900 dark:text-info-200 text-xs">
|
||||
{errorInfo.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-danger-600 dark:text-danger-400 flex-shrink-0">
|
||||
⚠️
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">
|
||||
{errorInfo.title}
|
||||
</div>
|
||||
<div className="text-xs text-danger-700 dark:text-danger-300 font-mono">
|
||||
Error Code: 0x
|
||||
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mb-3">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">
|
||||
Status:
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = stateIcons[stateVisual.iconName];
|
||||
return <Icon className="w-3.5 h-3.5" />;
|
||||
})()}
|
||||
<span>{machineStatusName}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Machine Info */}
|
||||
{machineInfo && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Max Area
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{(machineInfo.maxWidth / 10).toFixed(1)} ×{" "}
|
||||
{(machineInfo.maxHeight / 10).toFixed(1)} mm
|
||||
</span>
|
||||
</div>
|
||||
{machineInfo.totalCount !== undefined && (
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Total Stitches
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{machineInfo.totalCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDisconnectClick}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600 text-xs font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDisconnectConfirm}
|
||||
title="Confirm Disconnect"
|
||||
message={`The machine is currently ${machineStatusName.toLowerCase()}. Disconnecting may interrupt the operation. Are you sure you want to disconnect?`}
|
||||
confirmText="Disconnect Anyway"
|
||||
cancelText="Cancel"
|
||||
onConfirm={handleConfirmDisconnect}
|
||||
onCancel={() => setShowDisconnectConfirm(false)}
|
||||
variant="danger"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,14 @@ import {
|
|||
PatternBounds,
|
||||
CurrentPosition,
|
||||
} from "./KonvaComponents";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function PatternCanvas() {
|
||||
// Machine store
|
||||
|
|
@ -252,27 +260,30 @@ export function PatternCanvas() {
|
|||
: "text-gray-600 dark:text-gray-400";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}
|
||||
<Card
|
||||
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3 flex-shrink-0">
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Pattern Preview
|
||||
</h3>
|
||||
<CardTitle className="text-sm">Pattern Preview</CardTitle>
|
||||
{pesData ? (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} ×{" "}
|
||||
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||
</p>
|
||||
<CardDescription className="text-xs">
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "}
|
||||
×{" "}
|
||||
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "}
|
||||
mm
|
||||
</CardDescription>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<CardDescription className="text-xs">
|
||||
No pattern loaded
|
||||
</p>
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col">
|
||||
<div
|
||||
className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
|
||||
ref={containerRef}
|
||||
|
|
@ -475,42 +486,51 @@ export function PatternCanvas() {
|
|||
|
||||
{/* Zoom Controls Overlay */}
|
||||
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
|
||||
<button
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="w-7 h-7 sm:w-8 sm:h-8"
|
||||
onClick={handleCenterPattern}
|
||||
disabled={!pesData || patternUploaded || isUploading}
|
||||
title="Center Pattern in Hoop"
|
||||
>
|
||||
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
|
||||
</button>
|
||||
<button
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="w-7 h-7 sm:w-8 sm:h-8"
|
||||
onClick={handleZoomIn}
|
||||
title="Zoom In"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
|
||||
</button>
|
||||
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</Button>
|
||||
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
|
||||
{Math.round(stageScale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="w-7 h-7 sm:w-8 sm:h-8"
|
||||
onClick={handleZoomOut}
|
||||
title="Zoom Out"
|
||||
>
|
||||
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
|
||||
</button>
|
||||
<button
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1"
|
||||
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
|
||||
onClick={handleZoomReset}
|
||||
title="Reset Zoom"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
|
||||
</button>
|
||||
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
94
src/components/PatternCanvasPlaceholder.tsx
Normal file
94
src/components/PatternCanvasPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { PhotoIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export function PatternCanvasPlaceholder() {
|
||||
return (
|
||||
<Card
|
||||
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 border-gray-400 dark:border-gray-600`}
|
||||
>
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<PhotoIcon
|
||||
className={`w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm">Pattern Preview</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
No pattern loaded
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 flex-1 flex flex-col">
|
||||
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
|
||||
{/* Decorative background pattern */}
|
||||
<div className="absolute inset-0 opacity-5 dark:opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center relative z-10">
|
||||
<div className="relative inline-block mb-6">
|
||||
<svg
|
||||
className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-600 dark:text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
|
||||
No Pattern Loaded
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
|
||||
Connect to your machine and choose a PES embroidery file to see
|
||||
your design preview
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-primary-400 dark:bg-primary-500 rounded-full"></div>
|
||||
<span>Drag to Position</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-success-400 dark:bg-success-500 rounded-full"></div>
|
||||
<span>Zoom & Pan</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-accent-400 dark:bg-accent-500 rounded-full"></div>
|
||||
<span>Real-time Preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface PatternInfoProps {
|
||||
pesData: PesPatternData;
|
||||
|
|
@ -11,15 +18,34 @@ export function PatternInfo({
|
|||
}: PatternInfoProps) {
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs mb-2">
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded cursor-help">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Size
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{" "}
|
||||
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(
|
||||
1,
|
||||
)}{" "}
|
||||
x{" "}
|
||||
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(
|
||||
1,
|
||||
)}{" "}
|
||||
mm
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">Pattern dimensions (width × height)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded cursor-help">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
Stitches
|
||||
</span>
|
||||
|
|
@ -27,7 +53,8 @@ export function PatternInfo({
|
|||
{pesData.penStitches?.stitches.length.toLocaleString() ||
|
||||
pesData.stitchCount.toLocaleString()}
|
||||
{pesData.penStitches &&
|
||||
pesData.penStitches.stitches.length !== pesData.stitchCount && (
|
||||
pesData.penStitches.stitches.length !==
|
||||
pesData.stitchCount && (
|
||||
<span
|
||||
className="text-gray-500 dark:text-gray-500 font-normal ml-1"
|
||||
title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
|
||||
|
|
@ -37,7 +64,20 @@ export function PatternInfo({
|
|||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
{pesData.penStitches &&
|
||||
pesData.penStitches.stitches.length !== pesData.stitchCount
|
||||
? `Total stitches including lock stitches. Original file had ${pesData.stitchCount.toLocaleString()} stitches.`
|
||||
: "Total number of stitches in the pattern"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded cursor-help">
|
||||
<span className="text-gray-600 dark:text-gray-400 block">
|
||||
{showThreadBlocks ? "Colors / Blocks" : "Colors"}
|
||||
</span>
|
||||
|
|
@ -47,12 +87,25 @@ export function PatternInfo({
|
|||
: pesData.uniqueColors.length}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{showThreadBlocks
|
||||
? `${pesData.uniqueColors.length} unique ${pesData.uniqueColors.length === 1 ? "color" : "colors"} across ${pesData.threads.length} thread ${pesData.threads.length === 1 ? "block" : "blocks"}`
|
||||
: `${pesData.uniqueColors.length} unique ${pesData.uniqueColors.length === 1 ? "color" : "colors"} in the pattern`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<Separator className="mb-3" />
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Colors:
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<div className="flex gap-1">
|
||||
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
|
||||
// Primary metadata: brand and catalog number
|
||||
|
|
@ -85,20 +138,36 @@ export function PatternInfo({
|
|||
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
|
||||
|
||||
return (
|
||||
<Tooltip key={idx}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
key={idx}
|
||||
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
|
||||
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600 cursor-help"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
title={tooltipText}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-xs whitespace-pre-line">{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{pesData.uniqueColors.length > 8 && (
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300 leading-none">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300 leading-none cursor-help">
|
||||
+{pesData.uniqueColors.length - 8}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{pesData.uniqueColors.length - 8} more{" "}
|
||||
{pesData.uniqueColors.length - 8 === 1 ? "color" : "colors"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
export function PatternPreviewPlaceholder() {
|
||||
return (
|
||||
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
|
||||
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">
|
||||
Pattern Preview
|
||||
</h2>
|
||||
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
|
||||
{/* Decorative background pattern */}
|
||||
<div className="absolute inset-0 opacity-5 dark:opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center relative z-10">
|
||||
<div className="relative inline-block mb-6">
|
||||
<svg
|
||||
className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-600 dark:text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
|
||||
No Pattern Loaded
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
|
||||
Connect to your machine and choose a PES embroidery file to see your
|
||||
design preview
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-primary-400 dark:bg-primary-500 rounded-full"></div>
|
||||
<span>Drag to Position</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-success-400 dark:bg-success-500 rounded-full"></div>
|
||||
<span>Zoom & Pan</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-accent-400 dark:bg-accent-500 rounded-full"></div>
|
||||
<span>Real-time Preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,15 @@ import { usePatternStore } from "../stores/usePatternStore";
|
|||
import { canDeletePattern } from "../utils/machineStateHelpers";
|
||||
import { PatternInfo } from "./PatternInfo";
|
||||
import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function PatternSummaryCard() {
|
||||
// Machine store
|
||||
|
|
@ -27,51 +36,34 @@ export function PatternSummaryCard() {
|
|||
|
||||
const canDelete = canDeletePattern(machineStatus);
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-primary-600 dark:border-primary-500">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<Card className="p-0 gap-0 border-l-4 border-primary-600 dark:border-primary-500">
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Active Pattern
|
||||
</h3>
|
||||
<p
|
||||
className="text-xs text-gray-600 dark:text-gray-400 truncate"
|
||||
<CardTitle className="text-sm">Active Pattern</CardTitle>
|
||||
<CardDescription
|
||||
className="text-xs truncate"
|
||||
title={currentFileName}
|
||||
>
|
||||
{currentFileName}
|
||||
</p>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-4">
|
||||
<PatternInfo pesData={pesData} />
|
||||
|
||||
{canDelete && (
|
||||
<button
|
||||
<Button
|
||||
onClick={deletePattern}
|
||||
disabled={isDeleting}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-danger-50 dark:bg-danger-900/20 text-danger-700 dark:text-danger-300 rounded border border-danger-300 dark:border-danger-700 hover:bg-danger-100 dark:hover:bg-danger-900/30 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
variant="outline"
|
||||
className="w-full bg-danger-50 dark:bg-danger-900/20 text-danger-700 dark:text-danger-300 border-danger-300 dark:border-danger-700 hover:bg-danger-100 dark:hover:bg-danger-900/30"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-3 h-3 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -80,8 +72,9 @@ export function PatternSummaryCard() {
|
|||
Delete Pattern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ import {
|
|||
ArrowRightIcon,
|
||||
CircleStackIcon,
|
||||
PlayIcon,
|
||||
CheckBadgeIcon,
|
||||
ClockIcon,
|
||||
PauseCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
ChartBarIcon,
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
|
@ -19,9 +15,17 @@ import {
|
|||
canStartSewing,
|
||||
canStartMaskTrace,
|
||||
canResumeSewing,
|
||||
getStateVisualInfo,
|
||||
} from "../utils/machineStateHelpers";
|
||||
import { calculatePatternTime } from "../utils/timeCalculation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
export function ProgressMonitor() {
|
||||
// Machine store
|
||||
|
|
@ -55,8 +59,6 @@ export function ProgressMonitor() {
|
|||
const isMaskTraceComplete =
|
||||
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
|
||||
|
||||
const stateVisual = getStateVisualInfo(machineStatus);
|
||||
|
||||
// Use PEN stitch count as fallback when machine reports 0 total stitches
|
||||
const totalStitches = patternInfo
|
||||
? patternInfo.totalStitches === 0 && pesData?.penStitches
|
||||
|
|
@ -158,35 +160,22 @@ export function ProgressMonitor() {
|
|||
return () => window.removeEventListener("resize", checkScrollable);
|
||||
}, [colorBlocks]);
|
||||
|
||||
const stateIndicatorColors = {
|
||||
idle: "bg-info-50 dark:bg-info-900/20 border-info-600",
|
||||
info: "bg-info-50 dark:bg-info-900/20 border-info-600",
|
||||
active: "bg-warning-50 dark:bg-warning-900/20 border-warning-500",
|
||||
waiting: "bg-warning-50 dark:bg-warning-900/20 border-warning-500",
|
||||
warning: "bg-warning-50 dark:bg-warning-900/20 border-warning-500",
|
||||
complete: "bg-success-50 dark:bg-success-900/20 border-success-600",
|
||||
success: "bg-success-50 dark:bg-success-900/20 border-success-600",
|
||||
interrupted: "bg-danger-50 dark:bg-danger-900/20 border-danger-600",
|
||||
error: "bg-danger-50 dark:bg-danger-900/20 border-danger-600",
|
||||
danger: "bg-danger-50 dark:bg-danger-900/20 border-danger-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Sewing Progress
|
||||
</h3>
|
||||
<CardTitle className="text-sm">Sewing Progress</CardTitle>
|
||||
{sewingProgress && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<CardDescription className="text-xs">
|
||||
{progressPercent.toFixed(1)}% complete
|
||||
</p>
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
|
||||
{/* Pattern Info */}
|
||||
{patternInfo && (
|
||||
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
|
||||
|
|
@ -220,12 +209,10 @@ export function ProgressMonitor() {
|
|||
{/* Progress Bar */}
|
||||
{sewingProgress && (
|
||||
<div className="mb-3">
|
||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-600 to-accent-700 dark:from-accent-600 dark:to-accent-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||
|
|
@ -249,49 +236,6 @@ export function ProgressMonitor() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* State Visual Indicator */}
|
||||
{patternInfo &&
|
||||
(() => {
|
||||
const iconMap = {
|
||||
ready: (
|
||||
<ClockIcon className="w-5 h-5 text-info-600 dark:text-info-400" />
|
||||
),
|
||||
active: (
|
||||
<PlayIcon className="w-5 h-5 text-warning-600 dark:text-warning-400" />
|
||||
),
|
||||
waiting: (
|
||||
<PauseCircleIcon className="w-5 h-5 text-warning-600 dark:text-warning-400" />
|
||||
),
|
||||
complete: (
|
||||
<CheckBadgeIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
),
|
||||
interrupted: (
|
||||
<PauseCircleIcon className="w-5 h-5 text-danger-600 dark:text-danger-400" />
|
||||
),
|
||||
error: (
|
||||
<ExclamationCircleIcon className="w-5 h-5 text-danger-600 dark:text-danger-400" />
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{iconMap[stateVisual.iconName]}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-xs dark:text-gray-100">
|
||||
{stateVisual.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{stateVisual.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Color Blocks */}
|
||||
{colorBlocks.length > 0 && (
|
||||
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
|
||||
|
|
@ -312,7 +256,8 @@ export function ProgressMonitor() {
|
|||
let blockProgress = 0;
|
||||
if (isCurrent) {
|
||||
blockProgress =
|
||||
((currentStitch - block.startStitch) / block.stitchCount) *
|
||||
((currentStitch - block.startStitch) /
|
||||
block.stitchCount) *
|
||||
100;
|
||||
} else if (isCompleted) {
|
||||
blockProgress = 100;
|
||||
|
|
@ -326,8 +271,8 @@ export function ProgressMonitor() {
|
|||
isCompleted
|
||||
? "border-success-600 bg-success-50 dark:bg-success-900/20"
|
||||
: isCurrent
|
||||
? "border-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow"
|
||||
: "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70"
|
||||
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
|
||||
: "border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-800/50 opacity-70"
|
||||
}`}
|
||||
role="listitem"
|
||||
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
|
||||
|
|
@ -338,7 +283,6 @@ export function ProgressMonitor() {
|
|||
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: block.threadHex,
|
||||
...(isCurrent && { borderColor: "#9333ea" }),
|
||||
}}
|
||||
title={`Thread color: ${block.threadHex}`}
|
||||
aria-label={`Thread color ${block.threadHex}`}
|
||||
|
|
@ -395,7 +339,7 @@ export function ProgressMonitor() {
|
|||
/>
|
||||
) : isCurrent ? (
|
||||
<ArrowRightIcon
|
||||
className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse"
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 animate-pulse"
|
||||
aria-label="In progress"
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -408,17 +352,11 @@ export function ProgressMonitor() {
|
|||
|
||||
{/* Progress bar for current block */}
|
||||
{isCurrent && (
|
||||
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full"
|
||||
style={{ width: `${blockProgress}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(blockProgress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
<Progress
|
||||
value={blockProgress}
|
||||
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
|
||||
aria-label={`${Math.round(blockProgress)}% complete`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -436,36 +374,37 @@ export function ProgressMonitor() {
|
|||
<div className="flex gap-2 flex-shrink-0">
|
||||
{/* Resume has highest priority when available */}
|
||||
{canResumeSewing(machineStatus) && (
|
||||
<button
|
||||
<Button
|
||||
onClick={resumeSewing}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1"
|
||||
aria-label="Resume sewing the current pattern"
|
||||
>
|
||||
<PlayIcon className="w-3.5 h-3.5" />
|
||||
Resume Sewing
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Start Sewing - primary action, takes more space */}
|
||||
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
|
||||
<button
|
||||
<Button
|
||||
onClick={startSewing}
|
||||
disabled={isDeleting}
|
||||
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-[2]"
|
||||
aria-label="Start sewing the pattern"
|
||||
>
|
||||
<PlayIcon className="w-3.5 h-3.5" />
|
||||
Start Sewing
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Start Mask Trace - secondary action */}
|
||||
{canStartMaskTrace(machineStatus) && (
|
||||
<button
|
||||
<Button
|
||||
onClick={startMaskTrace}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
aria-label={
|
||||
isMaskTraceComplete
|
||||
? "Start mask trace again"
|
||||
|
|
@ -474,9 +413,10 @@ export function ProgressMonitor() {
|
|||
>
|
||||
<ArrowPathIcon className="w-3.5 h-3.5" />
|
||||
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SkeletonLoaderProps {
|
||||
className?: string;
|
||||
variant?: "text" | "rect" | "circle";
|
||||
|
|
@ -7,18 +10,13 @@ export function SkeletonLoader({
|
|||
className = "",
|
||||
variant = "rect",
|
||||
}: SkeletonLoaderProps) {
|
||||
const baseClasses =
|
||||
"animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]";
|
||||
|
||||
const variantClasses = {
|
||||
text: "h-4 rounded",
|
||||
rect: "rounded-lg",
|
||||
circle: "rounded-full",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${baseClasses} ${variantClasses[variant]} ${className}`} />
|
||||
);
|
||||
return <Skeleton className={cn(variantClasses[variant], className)} />;
|
||||
}
|
||||
|
||||
export function PatternCanvasSkeleton() {
|
||||
|
|
|
|||
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
47
src/components/ui/badge.tsx
Normal file
47
src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Badge, badgeVariants };
|
||||
63
src/components/ui/button.tsx
Normal file
63
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants };
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
59
src/components/ui/tooltip.tsx
Normal file
59
src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -8,6 +8,12 @@
|
|||
"types": ["vite/client", "web-bluetooth", "node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { defineConfig } from 'vite'
|
|||
import react from '@vitejs/plugin-react'
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { dirname, join } from 'path'
|
||||
import { dirname, join, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { readFileSync } from 'fs'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// Read version from package.json
|
||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
const appVersion = packageJson.version
|
||||
|
|
@ -141,6 +143,11 @@ export default defineConfig({
|
|||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue