mirror of
https://github.com/jhbruhn/respira.git
synced 2026-03-14 02:38:41 +00:00
Compare commits
No commits in common. "47c36f92ddec871d0e8570267c56f481e858700a" and "d31cb2f29e85ce35e5b8eb5c66324106c34492a6" have entirely different histories.
47c36f92dd
...
d31cb2f29e
34 changed files with 1637 additions and 6378 deletions
|
|
@ -8,8 +8,5 @@
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
},
|
}
|
||||||
"enabledMcpjsonServers": [
|
|
||||||
"shadcn"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"$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,28 +23,16 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@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",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
"konva": "^10.0.12",
|
"konva": "^10.0.12",
|
||||||
"lucide-react": "^0.562.0",
|
|
||||||
"pyodide": "^0.29.0",
|
"pyodide": "^0.29.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-konva": "^19.2.1",
|
"react-konva": "^19.2.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"update-electron-app": "^3.1.2",
|
"update-electron-app": "^3.1.2",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|
@ -74,7 +62,6 @@
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.7.4",
|
||||||
"shadcn": "^3.6.2",
|
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
|
|
|
||||||
132
src/App.css
132
src/App.css
|
|
@ -1,108 +1,4 @@
|
||||||
@import "tailwindcss";
|
@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
|
THEME DEFINITION - Tailwind v4
|
||||||
|
|
@ -110,28 +6,7 @@
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* SHADCN/UI COLORS - For bg-primary, bg-destructive, etc. */
|
/* PRIMARY - Main brand color (references Blue) */
|
||||||
--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-50: var(--color-blue-50);
|
||||||
--color-primary-100: var(--color-blue-100);
|
--color-primary-100: var(--color-blue-100);
|
||||||
--color-primary-200: var(--color-blue-200);
|
--color-primary-200: var(--color-blue-200);
|
||||||
|
|
@ -229,7 +104,7 @@
|
||||||
/* Canvas/Konva-specific colors for embroidery rendering */
|
/* Canvas/Konva-specific colors for embroidery rendering */
|
||||||
--color-canvas-grid: #e0e0e0;
|
--color-canvas-grid: #e0e0e0;
|
||||||
--color-canvas-origin: #888888;
|
--color-canvas-origin: #888888;
|
||||||
--color-canvas-hoop: #2196f3;
|
--color-canvas-hoop: #2196F3;
|
||||||
--color-canvas-bounds: #ff0000;
|
--color-canvas-bounds: #ff0000;
|
||||||
--color-canvas-position: #ff0000;
|
--color-canvas-position: #ff0000;
|
||||||
}
|
}
|
||||||
|
|
@ -294,8 +169,7 @@
|
||||||
|
|
||||||
/* Pulse glow effect - uses primary-600 */
|
/* Pulse glow effect - uses primary-600 */
|
||||||
@keyframes pulseGlow {
|
@keyframes pulseGlow {
|
||||||
0%,
|
0%, 100% {
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 rgb(37 99 235 / 0.4); /* primary-600 with 40% opacity */
|
box-shadow: 0 0 0 0 rgb(37 99 235 / 0.4); /* primary-600 with 40% opacity */
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useUIStore } from "./stores/useUIStore";
|
||||||
import { AppHeader } from "./components/AppHeader";
|
import { AppHeader } from "./components/AppHeader";
|
||||||
import { LeftSidebar } from "./components/LeftSidebar";
|
import { LeftSidebar } from "./components/LeftSidebar";
|
||||||
import { PatternCanvas } from "./components/PatternCanvas";
|
import { PatternCanvas } from "./components/PatternCanvas";
|
||||||
import { PatternCanvasPlaceholder } from "./components/PatternCanvasPlaceholder";
|
import { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder";
|
||||||
import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker";
|
import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ function App() {
|
||||||
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
|
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
<div className="h-screen flex flex-col bg-gray-300 dark:bg-gray-900 overflow-hidden">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
<div className="flex-1 p-4 sm:p-5 lg:p-6 w-full overflow-y-auto lg:overflow-hidden flex flex-col">
|
<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 */}
|
{/* Right Column - Pattern Preview */}
|
||||||
<div className="flex flex-col lg:overflow-hidden lg:h-full">
|
<div className="flex flex-col lg:overflow-hidden lg:h-full">
|
||||||
{pesData ? <PatternCanvas /> : <PatternCanvasPlaceholder />}
|
{pesData ? <PatternCanvas /> : <PatternPreviewPlaceholder />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useMachineStore } from "../stores/useMachineStore";
|
import { useMachineStore } from "../stores/useMachineStore";
|
||||||
import { useUIStore } from "../stores/useUIStore";
|
import { useUIStore } from "../stores/useUIStore";
|
||||||
import { WorkflowStepper } from "./WorkflowStepper";
|
import { WorkflowStepper } from "./WorkflowStepper";
|
||||||
import { ErrorPopoverContent } from "./ErrorPopover";
|
import { ErrorPopover } from "./ErrorPopover";
|
||||||
import { getStateVisualInfo } from "../utils/machineStateHelpers";
|
import { getStateVisualInfo } from "../utils/machineStateHelpers";
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
|
|
@ -12,16 +13,6 @@ import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} 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() {
|
export function AppHeader() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -48,12 +39,17 @@ export function AppHeader() {
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { pyodideError } = useUIStore(
|
const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
pyodideError: state.pyodideError,
|
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
|
// Get state visual info for header status badge
|
||||||
const stateVisual = getStateVisualInfo(machineStatus);
|
const stateVisual = getStateVisualInfo(machineStatus);
|
||||||
const stateIcons = {
|
const stateIcons = {
|
||||||
|
|
@ -66,172 +62,157 @@ export function AppHeader() {
|
||||||
};
|
};
|
||||||
const StatusIcon = stateIcons[stateVisual.iconName];
|
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 (
|
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">
|
||||||
<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">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
|
{/* Machine Connection Status - Responsive width column */}
|
||||||
{/* Machine Connection Status - Responsive width column */}
|
<div className="flex items-center gap-3 w-full lg:w-[280px]">
|
||||||
<div className="flex items-center gap-3 w-full lg:w-[280px]">
|
<div
|
||||||
<div
|
className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50"
|
||||||
className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50"
|
style={{ visibility: isConnected ? "visible" : "hidden" }}
|
||||||
style={{ visibility: isConnected ? "visible" : "hidden" }}
|
></div>
|
||||||
></div>
|
<div
|
||||||
<div
|
className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5"
|
||||||
className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5"
|
style={{ visibility: !isConnected ? "visible" : "hidden" }}
|
||||||
style={{ visibility: !isConnected ? "visible" : "hidden" }}
|
></div>
|
||||||
></div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">
|
||||||
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight">
|
Respira
|
||||||
Respira
|
</h1>
|
||||||
</h1>
|
{isConnected && machineInfo?.serialNumber && (
|
||||||
{isConnected && machineInfo?.serialNumber && (
|
<span
|
||||||
<Tooltip>
|
className="text-xs text-primary-200 cursor-help"
|
||||||
<TooltipTrigger asChild>
|
title={`Serial: ${machineInfo.serialNumber}${
|
||||||
<span className="text-xs text-primary-200 cursor-help">
|
machineInfo.macAddress
|
||||||
• {machineInfo.serialNumber}
|
? `\nMAC: ${machineInfo.macAddress}`
|
||||||
</span>
|
: ""
|
||||||
</TooltipTrigger>
|
}${
|
||||||
<TooltipContent className="max-w-xs">
|
machineInfo.totalCount !== undefined
|
||||||
<div className="text-sm space-y-1">
|
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
|
||||||
<p className="font-semibold">
|
: ""
|
||||||
Serial: {machineInfo.serialNumber}
|
}${
|
||||||
</p>
|
machineInfo.serviceCount !== undefined
|
||||||
{machineInfo.macAddress && (
|
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
|
||||||
<p className="text-xs">
|
: ""
|
||||||
MAC: {machineInfo.macAddress}
|
}`}
|
||||||
</p>
|
>
|
||||||
)}
|
• {machineInfo.serialNumber}
|
||||||
{machineInfo.totalCount !== undefined && (
|
</span>
|
||||||
<p className="text-xs">
|
)}
|
||||||
Total stitches:{" "}
|
{isPolling && (
|
||||||
{machineInfo.totalCount.toLocaleString()}
|
<ArrowPathIcon
|
||||||
</p>
|
className="w-3.5 h-3.5 text-primary-200 animate-spin"
|
||||||
)}
|
title="Auto-refreshing status"
|
||||||
{machineInfo.serviceCount !== undefined && (
|
/>
|
||||||
<p className="text-xs">
|
)}
|
||||||
Stitches since service:{" "}
|
</div>
|
||||||
{machineInfo.serviceCount.toLocaleString()}
|
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
|
||||||
</p>
|
{isConnected ? (
|
||||||
)}
|
<>
|
||||||
</div>
|
<button
|
||||||
</TooltipContent>
|
onClick={disconnect}
|
||||||
</Tooltip>
|
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"
|
||||||
{isPolling && (
|
aria-label="Disconnect from machine"
|
||||||
<Tooltip>
|
>
|
||||||
<TooltipTrigger asChild>
|
<XMarkIcon className="w-3 h-3" />
|
||||||
<div>
|
Disconnect
|
||||||
<ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" />
|
</button>
|
||||||
</div>
|
<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">
|
||||||
</TooltipTrigger>
|
<StatusIcon className="w-3 h-3" />
|
||||||
<TooltipContent>
|
{machineStatusName}
|
||||||
<p className="text-xs">Auto-refreshing machine status</p>
|
</span>
|
||||||
</TooltipContent>
|
</>
|
||||||
</Tooltip>
|
) : (
|
||||||
)}
|
<p className="text-xs text-primary-200">Not Connected</p>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
|
|
||||||
{isConnected ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
onClick={disconnect}
|
|
||||||
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>
|
|
||||||
<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}
|
|
||||||
</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 */}
|
{/* Error indicator - always render to prevent layout shift */}
|
||||||
<Popover>
|
<div className="relative">
|
||||||
<PopoverTrigger asChild>
|
<button
|
||||||
<Button
|
ref={errorButtonRef}
|
||||||
size="sm"
|
onClick={() => setErrorPopover(!showErrorPopover)}
|
||||||
variant="destructive"
|
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 ${
|
||||||
className={cn(
|
machineErrorMessage || pyodideError
|
||||||
"gap-1.5 flex-shrink-0",
|
? "cursor-pointer animate-pulse hover:animate-none"
|
||||||
machineErrorMessage || pyodideError
|
: "invisible pointer-events-none"
|
||||||
? "animate-pulse hover:animate-none"
|
}`}
|
||||||
: "invisible pointer-events-none",
|
title="Click to view error details"
|
||||||
)}
|
aria-label="View error details"
|
||||||
aria-label="View error details"
|
disabled={!(machineErrorMessage || pyodideError)}
|
||||||
disabled={!(machineErrorMessage || pyodideError)}
|
>
|
||||||
>
|
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
<span>
|
||||||
<span>
|
{(() => {
|
||||||
{(() => {
|
if (pyodideError) return "Python Error";
|
||||||
if (pyodideError) return "Python Error";
|
if (isPairingError) return "Pairing Required";
|
||||||
if (isPairingError) return "Pairing Required";
|
|
||||||
|
|
||||||
const errorMsg = machineErrorMessage || "";
|
const errorMsg = machineErrorMessage || "";
|
||||||
|
|
||||||
// Categorize by error message content
|
// Categorize by error message content
|
||||||
if (
|
if (
|
||||||
errorMsg.toLowerCase().includes("bluetooth") ||
|
errorMsg.toLowerCase().includes("bluetooth") ||
|
||||||
errorMsg.toLowerCase().includes("connection")
|
errorMsg.toLowerCase().includes("connection")
|
||||||
) {
|
) {
|
||||||
return "Connection Error";
|
return "Connection Error";
|
||||||
}
|
|
||||||
if (errorMsg.toLowerCase().includes("upload")) {
|
|
||||||
return "Upload Error";
|
|
||||||
}
|
|
||||||
if (errorMsg.toLowerCase().includes("pattern")) {
|
|
||||||
return "Pattern Error";
|
|
||||||
}
|
|
||||||
if (machineError !== undefined) {
|
|
||||||
return `Machine Error`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default fallback
|
|
||||||
return "Error";
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
{/* Error popover content */}
|
|
||||||
{(machineErrorMessage || pyodideError) && (
|
|
||||||
<ErrorPopoverContent
|
|
||||||
machineError={
|
|
||||||
machineError != 0xdd ? machineError : undefined
|
|
||||||
}
|
}
|
||||||
isPairingError={isPairingError}
|
if (errorMsg.toLowerCase().includes("upload")) {
|
||||||
errorMessage={machineErrorMessage}
|
return "Upload Error";
|
||||||
pyodideError={pyodideError}
|
}
|
||||||
/>
|
if (errorMsg.toLowerCase().includes("pattern")) {
|
||||||
)}
|
return "Pattern Error";
|
||||||
</Popover>
|
}
|
||||||
|
if (machineError !== undefined) {
|
||||||
|
return `Machine Error`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return "Error";
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Error popover */}
|
||||||
|
{showErrorPopover && (machineErrorMessage || pyodideError) && (
|
||||||
|
<ErrorPopover
|
||||||
|
ref={errorPopoverRef}
|
||||||
|
machineError={machineError}
|
||||||
|
isPairingError={isPairingError}
|
||||||
|
errorMessage={machineErrorMessage}
|
||||||
|
pyodideError={pyodideError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workflow Stepper - Flexible width column */}
|
|
||||||
<div>
|
|
||||||
<WorkflowStepper />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
</TooltipProvider>
|
{/* Workflow Stepper - Flexible width column */}
|
||||||
|
<div>
|
||||||
|
<WorkflowStepper />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import type { BluetoothDevice } from "../types/electron";
|
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() {
|
export function BluetoothDevicePicker() {
|
||||||
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
|
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
|
||||||
|
|
@ -49,71 +40,111 @@ export function BluetoothDevicePicker() {
|
||||||
setIsScanning(false);
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
<div
|
||||||
<DialogContent
|
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
|
||||||
className="border-t-4 border-primary-600 dark:border-primary-500"
|
onClick={handleCancel}
|
||||||
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"
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
|
||||||
<DialogTitle>Select Bluetooth Device</DialogTitle>
|
<h3
|
||||||
<DialogDescription>
|
id="bluetooth-picker-title"
|
||||||
{isScanning && devices.length === 0 ? (
|
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
|
||||||
<div className="flex items-center gap-3 py-2">
|
>
|
||||||
<svg
|
Select Bluetooth Device
|
||||||
className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
|
</h3>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</div>
|
||||||
fill="none"
|
<div className="p-6">
|
||||||
viewBox="0 0 24 24"
|
{isScanning && devices.length === 0 ? (
|
||||||
>
|
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
||||||
<circle
|
<svg
|
||||||
className="opacity-25"
|
className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
|
||||||
cx="12"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
cy="12"
|
fill="none"
|
||||||
r="10"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Scanning for Bluetooth devices...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
`${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
|
|
||||||
key={device.deviceId}
|
|
||||||
onClick={() => handleSelectDevice(device.deviceId)}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full h-auto px-4 py-3 justify-start"
|
|
||||||
>
|
>
|
||||||
<div className="text-left">
|
<circle
|
||||||
<div className="font-semibold">{device.deviceName}</div>
|
className="opacity-25"
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
cx="12"
|
||||||
{device.deviceId}
|
cy="12"
|
||||||
</div>
|
r="10"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
</Button>
|
strokeWidth="4"
|
||||||
))}
|
></circle>
|
||||||
</div>
|
<path
|
||||||
)}
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
<DialogFooter>
|
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"
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span id="bluetooth-picker-message">
|
||||||
|
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>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{devices.map((device) => (
|
||||||
|
<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}`}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{device.deviceId}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,4 @@
|
||||||
import {
|
import { useEffect, useCallback } from "react";
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -31,32 +21,75 @@ export function ConfirmDialog({
|
||||||
onCancel,
|
onCancel,
|
||||||
variant = "warning",
|
variant = "warning",
|
||||||
}: ConfirmDialogProps) {
|
}: 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 (
|
return (
|
||||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
<div
|
||||||
<AlertDialogContent
|
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
|
||||||
className={cn(
|
onClick={onCancel}
|
||||||
variant === "danger"
|
>
|
||||||
? "border-t-4 border-danger-600 dark:border-danger-500"
|
<div
|
||||||
: "border-t-4 border-warning-500 dark:border-warning-600",
|
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"
|
||||||
>
|
>
|
||||||
<AlertDialogHeader>
|
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
<h3
|
||||||
<AlertDialogDescription>{message}</AlertDialogDescription>
|
id="dialog-title"
|
||||||
</AlertDialogHeader>
|
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
|
||||||
<AlertDialogFooter>
|
>
|
||||||
<AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
|
{title}
|
||||||
<AlertDialogAction
|
</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}
|
onClick={onConfirm}
|
||||||
className={cn(
|
className={
|
||||||
variant === "danger" &&
|
variant === "danger"
|
||||||
"bg-danger-600 hover:bg-danger-700 dark:bg-danger-700 dark:hover:bg-danger-600",
|
? "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}`}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</AlertDialogAction>
|
</button>
|
||||||
</AlertDialogFooter>
|
</div>
|
||||||
</AlertDialogContent>
|
</div>
|
||||||
</AlertDialog>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ import { useShallow } from "zustand/react/shallow";
|
||||||
import { useMachineStore } from "../stores/useMachineStore";
|
import { useMachineStore } from "../stores/useMachineStore";
|
||||||
import { isBluetoothSupported } from "../utils/bluetoothSupport";
|
import { isBluetoothSupported } from "../utils/bluetoothSupport";
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
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() {
|
export function ConnectionPrompt() {
|
||||||
const { connect } = useMachineStore(
|
const { connect } = useMachineStore(
|
||||||
|
|
@ -15,74 +12,75 @@ export function ConnectionPrompt() {
|
||||||
|
|
||||||
if (isBluetoothSupported()) {
|
if (isBluetoothSupported()) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-0 gap-0 border-l-4 border-gray-400 dark:border-gray-600">
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md 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="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">
|
||||||
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
|
<svg
|
||||||
<svg
|
className="w-6 h-6"
|
||||||
className="w-6 h-6"
|
fill="none"
|
||||||
fill="none"
|
stroke="currentColor"
|
||||||
stroke="currentColor"
|
viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
>
|
<path
|
||||||
<path
|
strokeLinecap="round"
|
||||||
strokeLinecap="round"
|
strokeLinejoin="round"
|
||||||
strokeLinejoin="round"
|
strokeWidth={2}
|
||||||
strokeWidth={2}
|
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
|
||||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
|
/>
|
||||||
/>
|
</svg>
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
|
||||||
Get Started
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
Connect to your embroidery machine
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={connect} className="w-full">
|
<div className="flex-1 min-w-0">
|
||||||
Connect to Machine
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
</Button>
|
Get Started
|
||||||
</CardContent>
|
</h3>
|
||||||
</Card>
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Connect to your embroidery machine
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Connect to Machine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert className="bg-warning-50 dark:bg-warning-900/20 border-l-4 border-warning-500 dark:border-warning-600">
|
<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">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-warning-600 dark:text-warning-400" />
|
<div className="flex items-start gap-3">
|
||||||
<AlertDescription className="space-y-3">
|
<ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">
|
<h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">
|
||||||
Browser Not Supported
|
Browser Not Supported
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-warning-800 dark:text-warning-200">
|
<p className="text-sm text-warning-800 dark:text-warning-200 mb-3">
|
||||||
Your browser doesn't support Web Bluetooth, which is required to
|
Your browser doesn't support Web Bluetooth, which is required to
|
||||||
connect to your embroidery machine.
|
connect to your embroidery machine.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100">
|
||||||
|
Please try one of these options:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-warning-800 dark:text-warning-200 space-y-1.5 ml-4 list-disc">
|
||||||
|
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
|
||||||
|
<li>
|
||||||
|
Download the Desktop app from{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/jhbruhn/respira/releases/latest"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-semibold underline hover:text-warning-900 dark:hover:text-warning-50 transition-colors"
|
||||||
|
>
|
||||||
|
GitHub Releases
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100">
|
</div>
|
||||||
Please try one of these options:
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm text-warning-800 dark:text-warning-200 space-y-1.5 ml-4 list-disc">
|
|
||||||
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
|
|
||||||
<li>
|
|
||||||
Download the Desktop app from{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/jhbruhn/respira/releases/latest"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-semibold underline hover:text-warning-900 dark:hover:text-warning-50 transition-colors"
|
|
||||||
>
|
|
||||||
GitHub Releases
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,95 @@
|
||||||
|
import { forwardRef } from "react";
|
||||||
import {
|
import {
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import { getErrorDetails } from "../utils/errorCodeHelpers";
|
import { getErrorDetails } from "../utils/errorCodeHelpers";
|
||||||
import { PopoverContent } from "@/components/ui/popover";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ErrorPopoverContentProps {
|
interface ErrorPopoverProps {
|
||||||
machineError?: number;
|
machineError?: number;
|
||||||
isPairingError: boolean;
|
isPairingError: boolean;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
pyodideError?: string | null;
|
pyodideError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorPopoverContent({
|
export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
||||||
machineError,
|
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
|
||||||
isPairingError,
|
const errorDetails = getErrorDetails(machineError);
|
||||||
errorMessage,
|
const isPairingErr = isPairingError;
|
||||||
pyodideError,
|
const errorMsg = pyodideError || errorMessage || "";
|
||||||
}: ErrorPopoverContentProps) {
|
const isInfo = isPairingErr || errorDetails?.isInformational;
|
||||||
const errorDetails = getErrorDetails(machineError);
|
|
||||||
const isPairingErr = isPairingError;
|
|
||||||
const errorMsg = pyodideError || errorMessage || "";
|
|
||||||
const isInfo = isPairingErr || errorDetails?.isInformational;
|
|
||||||
|
|
||||||
const bgColor = isInfo
|
const bgColor = isInfo
|
||||||
? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
|
? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
|
||||||
: "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
|
: "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
|
||||||
|
|
||||||
const iconColor = isInfo
|
const iconColor = isInfo
|
||||||
? "text-info-600 dark:text-info-400"
|
? "text-info-600 dark:text-info-400"
|
||||||
: "text-danger-600 dark:text-danger-400";
|
: "text-danger-600 dark:text-danger-400";
|
||||||
|
|
||||||
const textColor = isInfo
|
const textColor = isInfo
|
||||||
? "text-info-900 dark:text-info-200"
|
? "text-info-900 dark:text-info-200"
|
||||||
: "text-danger-900 dark:text-danger-200";
|
: "text-danger-900 dark:text-danger-200";
|
||||||
|
|
||||||
const descColor = isInfo
|
const descColor = isInfo
|
||||||
? "text-info-800 dark:text-info-300"
|
? "text-info-800 dark:text-info-300"
|
||||||
: "text-danger-800 dark:text-danger-300";
|
: "text-danger-800 dark:text-danger-300";
|
||||||
|
|
||||||
const listColor = isInfo
|
const listColor = isInfo
|
||||||
? "text-info-700 dark:text-info-300"
|
? "text-info-700 dark:text-info-300"
|
||||||
: "text-danger-700 dark:text-danger-300";
|
: "text-danger-700 dark:text-danger-300";
|
||||||
|
|
||||||
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
|
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
|
||||||
const title =
|
const title =
|
||||||
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
|
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverContent
|
<div
|
||||||
className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)}
|
ref={ref}
|
||||||
align="start"
|
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
|
||||||
>
|
role="dialog"
|
||||||
<div className="flex items-start gap-3">
|
aria-label="Error details"
|
||||||
<Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
|
>
|
||||||
<div className="flex-1">
|
<div
|
||||||
<h3 className={cn("text-base font-semibold mb-2", textColor)}>
|
className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
|
||||||
{title}
|
>
|
||||||
</h3>
|
<div className="flex items-start gap-3">
|
||||||
<p className={cn("text-sm mb-3", descColor)}>
|
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
||||||
{errorDetails?.description || errorMsg}
|
<div className="flex-1">
|
||||||
</p>
|
<h3 className={`text-base font-semibold ${textColor} mb-2`}>
|
||||||
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
{title}
|
||||||
<>
|
</h3>
|
||||||
<h4 className={cn("text-sm font-semibold mb-2", textColor)}>
|
<p className={`text-sm ${descColor} mb-3`}>
|
||||||
{isInfo ? "Steps:" : "How to Fix:"}
|
{errorDetails?.description || errorMsg}
|
||||||
</h4>
|
</p>
|
||||||
<ol
|
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
||||||
className={cn(
|
<>
|
||||||
"list-decimal list-inside text-sm space-y-1.5",
|
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
|
||||||
listColor,
|
{isInfo ? "Steps:" : "How to Fix:"}
|
||||||
)}
|
</h4>
|
||||||
>
|
<ol
|
||||||
{errorDetails.solutions.map((solution, index) => (
|
className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}
|
||||||
<li key={index} className="pl-2">
|
>
|
||||||
{solution}
|
{errorDetails.solutions.map((solution, index) => (
|
||||||
</li>
|
<li key={index} className="pl-2">
|
||||||
))}
|
{solution}
|
||||||
</ol>
|
</li>
|
||||||
</>
|
))}
|
||||||
)}
|
</ol>
|
||||||
{machineError !== undefined && !errorDetails?.isInformational && (
|
</>
|
||||||
<p className={cn("text-xs mt-3 font-mono", descColor)}>
|
)}
|
||||||
Error Code: 0x
|
{machineError !== undefined && !errorDetails?.isInformational && (
|
||||||
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
<p className={`text-xs ${descColor} mt-3 font-mono`}>
|
||||||
</p>
|
Error Code: 0x
|
||||||
)}
|
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
);
|
||||||
);
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
|
ErrorPopover.displayName = "ErrorPopover";
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,6 @@ import {
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import { createFileService } from "../platform";
|
import { createFileService } from "../platform";
|
||||||
import type { IFileService } from "../platform/interfaces/IFileService";
|
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() {
|
export function FileUpload() {
|
||||||
// Machine store
|
// Machine store
|
||||||
|
|
@ -208,214 +202,229 @@ export function FileUpload() {
|
||||||
: "text-gray-600 dark:text-gray-400";
|
: "text-gray-600 dark:text-gray-400";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("p-0 gap-0 border-l-4", borderColor)}>
|
<div
|
||||||
<CardContent className="p-4 rounded-lg">
|
className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}
|
||||||
<div className="flex items-start gap-3 mb-3">
|
>
|
||||||
<DocumentTextIcon
|
<div className="flex items-start gap-3 mb-3">
|
||||||
className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)}
|
<DocumentTextIcon
|
||||||
/>
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
Pattern File
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
</h3>
|
Pattern File
|
||||||
{pesData && displayFileName ? (
|
</h3>
|
||||||
<p
|
{pesData && displayFileName ? (
|
||||||
className="text-xs text-gray-600 dark:text-gray-400 truncate"
|
<p
|
||||||
title={displayFileName}
|
className="text-xs text-gray-600 dark:text-gray-400 truncate"
|
||||||
>
|
title={displayFileName}
|
||||||
{displayFileName}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
No pattern loaded
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resumeAvailable && resumeFileName && (
|
|
||||||
<div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
|
|
||||||
<p className="text-xs text-success-800 dark:text-success-200">
|
|
||||||
<strong>Cached:</strong> "{resumeFileName}"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && <PatternInfoSkeleton />}
|
|
||||||
|
|
||||||
{!isLoading && pesData && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<PatternInfo pesData={pesData} showThreadBlocks />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 mb-3">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".pes"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
id="file-input"
|
|
||||||
className="hidden"
|
|
||||||
disabled={isLoading || patternUploaded || isUploading}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
asChild={!fileService.hasNativeDialogs()}
|
|
||||||
onClick={
|
|
||||||
fileService.hasNativeDialogs()
|
|
||||||
? () => handleFileChange()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
disabled={isLoading || patternUploaded || isUploading}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-[2]"
|
|
||||||
>
|
|
||||||
{fileService.hasNativeDialogs() ? (
|
|
||||||
<>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<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 ? (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{pesData &&
|
|
||||||
canUploadPattern(machineStatus) &&
|
|
||||||
!patternUploaded &&
|
|
||||||
uploadProgress < 100 && (
|
|
||||||
<Button
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={!isConnected || isUploading || !boundsCheck.fits}
|
|
||||||
className="flex-1"
|
|
||||||
aria-label={
|
|
||||||
isUploading
|
|
||||||
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
|
|
||||||
: boundsCheck.error || "Upload pattern to machine"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
{uploadProgress > 0
|
|
||||||
? uploadProgress.toFixed(0) + "%"
|
|
||||||
: "Uploading"}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowUpTrayIcon className="w-3.5 h-3.5" />
|
|
||||||
Upload
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pyodide initialization progress indicator - shown when initializing or waiting */}
|
|
||||||
{!pyodideReady && pyodideProgress > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
|
||||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
|
||||||
{isLoading && !pyodideReady
|
|
||||||
? "Please wait - initializing Python environment..."
|
|
||||||
: pyodideLoadingStep || "Initializing Python environment..."}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-bold text-primary-600 dark:text-primary-400">
|
|
||||||
{pyodideProgress.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</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"
|
|
||||||
: "This only happens once on first use"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error/warning messages with smooth transition - placed after buttons */}
|
|
||||||
<div
|
|
||||||
className="transition-all duration-200 ease-in-out overflow-hidden"
|
|
||||||
style={{
|
|
||||||
maxHeight:
|
|
||||||
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
|
|
||||||
? "200px"
|
|
||||||
: "0px",
|
|
||||||
marginTop:
|
|
||||||
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
|
|
||||||
? "12px"
|
|
||||||
: "0px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pesData && !canUploadPattern(machineStatus) && (
|
|
||||||
<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)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pesData && boundsCheck.error && (
|
|
||||||
<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">
|
{displayFileName}
|
||||||
<strong>Pattern too large:</strong> {boundsCheck.error}
|
</p>
|
||||||
</AlertDescription>
|
) : (
|
||||||
</Alert>
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
No pattern loaded
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isUploading && uploadProgress < 100 && (
|
{resumeAvailable && resumeFileName && (
|
||||||
<div className="mt-3">
|
<div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
<p className="text-xs text-success-800 dark:text-success-200">
|
||||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
<strong>Cached:</strong> "{resumeFileName}"
|
||||||
Uploading
|
</p>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
|
)}
|
||||||
{uploadProgress > 0
|
|
||||||
? uploadProgress.toFixed(1) + "%"
|
{isLoading && <PatternInfoSkeleton />}
|
||||||
: "Starting..."}
|
|
||||||
</span>
|
{!isLoading && pesData && (
|
||||||
</div>
|
<div className="mb-3">
|
||||||
<Progress
|
<PatternInfo pesData={pesData} showThreadBlocks />
|
||||||
value={uploadProgress}
|
</div>
|
||||||
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 className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pes"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
id="file-input"
|
||||||
|
className="hidden"
|
||||||
|
disabled={isLoading || patternUploaded || isUploading}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{pesData &&
|
||||||
|
canUploadPattern(machineStatus) &&
|
||||||
|
!patternUploaded &&
|
||||||
|
uploadProgress < 100 && (
|
||||||
|
<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"
|
||||||
|
aria-label={
|
||||||
|
isUploading
|
||||||
|
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
|
||||||
|
: boundsCheck.error || "Upload pattern to machine"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
{uploadProgress > 0
|
||||||
|
? uploadProgress.toFixed(0) + "%"
|
||||||
|
: "Uploading"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
|
||||||
|
Upload
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pyodide initialization progress indicator - shown when initializing or waiting */}
|
||||||
|
{!pyodideReady && pyodideProgress > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
{isLoading && !pyodideReady
|
||||||
|
? "Please wait - initializing Python environment..."
|
||||||
|
: pyodideLoadingStep || "Initializing Python environment..."}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{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>
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
|
||||||
|
{isLoading && !pyodideReady
|
||||||
|
? "File dialog will open automatically when ready"
|
||||||
|
: "This only happens once on first use"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error/warning messages with smooth transition - placed after buttons */}
|
||||||
|
<div
|
||||||
|
className="transition-all duration-200 ease-in-out overflow-hidden"
|
||||||
|
style={{
|
||||||
|
maxHeight:
|
||||||
|
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
|
||||||
|
? "200px"
|
||||||
|
: "0px",
|
||||||
|
marginTop:
|
||||||
|
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
|
||||||
|
? "12px"
|
||||||
|
: "0px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
Cannot upload while {getMachineStateCategory(machineStatus)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{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">
|
||||||
|
<strong>Pattern too large:</strong> {boundsCheck.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUploading && uploadProgress < 100 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
|
Uploading
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
|
||||||
|
{uploadProgress > 0
|
||||||
|
? uploadProgress.toFixed(1) + "%"
|
||||||
|
: "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}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
224
src/components/MachineConnection.tsx
Normal file
224
src/components/MachineConnection.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
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,14 +22,6 @@ import {
|
||||||
PatternBounds,
|
PatternBounds,
|
||||||
CurrentPosition,
|
CurrentPosition,
|
||||||
} from "./KonvaComponents";
|
} from "./KonvaComponents";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export function PatternCanvas() {
|
export function PatternCanvas() {
|
||||||
// Machine store
|
// Machine store
|
||||||
|
|
@ -260,101 +252,126 @@ export function PatternCanvas() {
|
||||||
: "text-gray-600 dark:text-gray-400";
|
: "text-gray-600 dark:text-gray-400";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
|
className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}
|
||||||
>
|
>
|
||||||
<CardHeader className="p-4 pb-3">
|
<div className="flex items-start gap-3 mb-3 flex-shrink-0">
|
||||||
<div className="flex items-start gap-3">
|
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
||||||
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
<CardTitle className="text-sm">Pattern Preview</CardTitle>
|
Pattern Preview
|
||||||
{pesData ? (
|
</h3>
|
||||||
<CardDescription className="text-xs">
|
{pesData ? (
|
||||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "}
|
<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)}{" "}
|
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||||
mm
|
</p>
|
||||||
</CardDescription>
|
) : (
|
||||||
) : (
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
<CardDescription className="text-xs">
|
No pattern loaded
|
||||||
No pattern loaded
|
</p>
|
||||||
</CardDescription>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col">
|
<div
|
||||||
<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"
|
||||||
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}
|
||||||
ref={containerRef}
|
>
|
||||||
>
|
{containerSize.width > 0 && (
|
||||||
{containerSize.width > 0 && (
|
<Stage
|
||||||
<Stage
|
width={containerSize.width}
|
||||||
width={containerSize.width}
|
height={containerSize.height}
|
||||||
height={containerSize.height}
|
x={stagePos.x}
|
||||||
x={stagePos.x}
|
y={stagePos.y}
|
||||||
y={stagePos.y}
|
scaleX={stageScale}
|
||||||
scaleX={stageScale}
|
scaleY={stageScale}
|
||||||
scaleY={stageScale}
|
draggable
|
||||||
draggable
|
onWheel={handleWheel}
|
||||||
onWheel={handleWheel}
|
onDragStart={() => {
|
||||||
onDragStart={() => {
|
if (stageRef.current) {
|
||||||
if (stageRef.current) {
|
stageRef.current.container().style.cursor = "grabbing";
|
||||||
stageRef.current.container().style.cursor = "grabbing";
|
}
|
||||||
}
|
}}
|
||||||
}}
|
onDragEnd={() => {
|
||||||
onDragEnd={() => {
|
if (stageRef.current) {
|
||||||
if (stageRef.current) {
|
stageRef.current.container().style.cursor = "grab";
|
||||||
stageRef.current.container().style.cursor = "grab";
|
}
|
||||||
}
|
}}
|
||||||
}}
|
ref={(node) => {
|
||||||
ref={(node) => {
|
stageRef.current = node;
|
||||||
stageRef.current = node;
|
if (node) {
|
||||||
if (node) {
|
node.container().style.cursor = "grab";
|
||||||
node.container().style.cursor = "grab";
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{/* Background layer: grid, origin, hoop */}
|
||||||
{/* Background layer: grid, origin, hoop */}
|
<Layer>
|
||||||
<Layer>
|
{pesData && (
|
||||||
{pesData && (
|
<>
|
||||||
<>
|
<Grid
|
||||||
<Grid
|
gridSize={100}
|
||||||
gridSize={100}
|
bounds={pesData.bounds}
|
||||||
bounds={pesData.bounds}
|
machineInfo={machineInfo}
|
||||||
machineInfo={machineInfo}
|
/>
|
||||||
/>
|
<Origin />
|
||||||
<Origin />
|
{machineInfo && <Hoop machineInfo={machineInfo} />}
|
||||||
{machineInfo && <Hoop machineInfo={machineInfo} />}
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</Layer>
|
||||||
</Layer>
|
|
||||||
|
|
||||||
{/* Pattern layer: draggable stitches and bounds */}
|
{/* Pattern layer: draggable stitches and bounds */}
|
||||||
<Layer>
|
<Layer>
|
||||||
{pesData && (
|
{pesData && (
|
||||||
<Group
|
<Group
|
||||||
name="pattern-group"
|
name="pattern-group"
|
||||||
draggable={!patternUploaded && !isUploading}
|
draggable={!patternUploaded && !isUploading}
|
||||||
x={localPatternOffset.x}
|
x={localPatternOffset.x}
|
||||||
y={localPatternOffset.y}
|
y={localPatternOffset.y}
|
||||||
onDragEnd={handlePatternDragEnd}
|
onDragEnd={handlePatternDragEnd}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (stage && !patternUploaded && !isUploading)
|
if (stage && !patternUploaded && !isUploading)
|
||||||
stage.container().style.cursor = "move";
|
stage.container().style.cursor = "move";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (stage && !patternUploaded && !isUploading)
|
if (stage && !patternUploaded && !isUploading)
|
||||||
stage.container().style.cursor = "grab";
|
stage.container().style.cursor = "grab";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stitches
|
<Stitches
|
||||||
|
stitches={pesData.penStitches.stitches.map(
|
||||||
|
(s, i): [number, number, number, number] => {
|
||||||
|
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
|
||||||
|
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
|
||||||
|
const colorIndex =
|
||||||
|
pesData.penStitches.colorBlocks.find(
|
||||||
|
(b) => i >= b.startStitch && i <= b.endStitch,
|
||||||
|
)?.colorIndex ?? 0;
|
||||||
|
return [s.x, s.y, cmd, colorIndex];
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
pesData={pesData}
|
||||||
|
currentStitchIndex={sewingProgress?.currentStitch || 0}
|
||||||
|
showProgress={patternUploaded || isUploading}
|
||||||
|
/>
|
||||||
|
<PatternBounds bounds={pesData.bounds} />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Layer>
|
||||||
|
|
||||||
|
{/* Current position layer */}
|
||||||
|
<Layer>
|
||||||
|
{pesData &&
|
||||||
|
pesData.penStitches &&
|
||||||
|
sewingProgress &&
|
||||||
|
sewingProgress.currentStitch > 0 && (
|
||||||
|
<Group x={localPatternOffset.x} y={localPatternOffset.y}>
|
||||||
|
<CurrentPosition
|
||||||
|
currentStitchIndex={sewingProgress.currentStitch}
|
||||||
stitches={pesData.penStitches.stitches.map(
|
stitches={pesData.penStitches.stitches.map(
|
||||||
(s, i): [number, number, number, number] => {
|
(s, i): [number, number, number, number] => {
|
||||||
// Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
|
const cmd = s.isJump ? 0x10 : 0;
|
||||||
const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
|
|
||||||
const colorIndex =
|
const colorIndex =
|
||||||
pesData.penStitches.colorBlocks.find(
|
pesData.penStitches.colorBlocks.find(
|
||||||
(b) => i >= b.startStitch && i <= b.endStitch,
|
(b) => i >= b.startStitch && i <= b.endStitch,
|
||||||
|
|
@ -362,175 +379,138 @@ export function PatternCanvas() {
|
||||||
return [s.x, s.y, cmd, colorIndex];
|
return [s.x, s.y, cmd, colorIndex];
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
pesData={pesData}
|
|
||||||
currentStitchIndex={sewingProgress?.currentStitch || 0}
|
|
||||||
showProgress={patternUploaded || isUploading}
|
|
||||||
/>
|
/>
|
||||||
<PatternBounds bounds={pesData.bounds} />
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Layer>
|
</Layer>
|
||||||
|
</Stage>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Current position layer */}
|
{/* Placeholder overlay when no pattern is loaded */}
|
||||||
<Layer>
|
{!pesData && (
|
||||||
{pesData &&
|
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
|
||||||
pesData.penStitches &&
|
Load a PES file to preview the pattern
|
||||||
sewingProgress &&
|
</div>
|
||||||
sewingProgress.currentStitch > 0 && (
|
)}
|
||||||
<Group x={localPatternOffset.x} y={localPatternOffset.y}>
|
|
||||||
<CurrentPosition
|
|
||||||
currentStitchIndex={sewingProgress.currentStitch}
|
|
||||||
stitches={pesData.penStitches.stitches.map(
|
|
||||||
(s, i): [number, number, number, number] => {
|
|
||||||
const cmd = s.isJump ? 0x10 : 0;
|
|
||||||
const colorIndex =
|
|
||||||
pesData.penStitches.colorBlocks.find(
|
|
||||||
(b) => i >= b.startStitch && i <= b.endStitch,
|
|
||||||
)?.colorIndex ?? 0;
|
|
||||||
return [s.x, s.y, cmd, colorIndex];
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Layer>
|
|
||||||
</Stage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Placeholder overlay when no pattern is loaded */}
|
{/* Pattern info overlays */}
|
||||||
{!pesData && (
|
{pesData && (
|
||||||
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
|
<>
|
||||||
Load a PES file to preview the pattern
|
{/* Thread Legend Overlay */}
|
||||||
</div>
|
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
|
||||||
)}
|
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
|
||||||
|
Colors
|
||||||
|
</h4>
|
||||||
|
{pesData.uniqueColors.map((color, idx) => {
|
||||||
|
// Primary metadata: brand and catalog number
|
||||||
|
const primaryMetadata = [
|
||||||
|
color.brand,
|
||||||
|
color.catalogNumber ? `#${color.catalogNumber}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
{/* Pattern info overlays */}
|
// Secondary metadata: chart and description
|
||||||
{pesData && (
|
const secondaryMetadata = [color.chart, color.description]
|
||||||
<>
|
.filter(Boolean)
|
||||||
{/* Thread Legend Overlay */}
|
.join(" ");
|
||||||
<div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
|
|
||||||
<h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
|
|
||||||
Colors
|
|
||||||
</h4>
|
|
||||||
{pesData.uniqueColors.map((color, idx) => {
|
|
||||||
// Primary metadata: brand and catalog number
|
|
||||||
const primaryMetadata = [
|
|
||||||
color.brand,
|
|
||||||
color.catalogNumber ? `#${color.catalogNumber}` : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
// Secondary metadata: chart and description
|
return (
|
||||||
const secondaryMetadata = [color.chart, color.description]
|
<div
|
||||||
.filter(Boolean)
|
key={idx}
|
||||||
.join(" ");
|
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
|
||||||
|
>
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={idx}
|
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
|
||||||
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
|
style={{ backgroundColor: color.hex }}
|
||||||
>
|
/>
|
||||||
<div
|
<div className="flex-1 min-w-0">
|
||||||
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
|
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
|
||||||
style={{ backgroundColor: color.hex }}
|
Color {idx + 1}
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
Color {idx + 1}
|
|
||||||
</div>
|
|
||||||
{(primaryMetadata || secondaryMetadata) && (
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
|
|
||||||
{primaryMetadata}
|
|
||||||
{primaryMetadata && secondaryMetadata && (
|
|
||||||
<span className="mx-1">•</span>
|
|
||||||
)}
|
|
||||||
{secondaryMetadata}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{(primaryMetadata || secondaryMetadata) && (
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
|
||||||
|
{primaryMetadata}
|
||||||
|
{primaryMetadata && secondaryMetadata && (
|
||||||
|
<span className="mx-1">•</span>
|
||||||
|
)}
|
||||||
|
{secondaryMetadata}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pattern Offset Indicator */}
|
|
||||||
<div
|
|
||||||
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
|
|
||||||
patternUploaded
|
|
||||||
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
|
|
||||||
: "bg-white/95 dark:bg-gray-800/95"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
|
||||||
Pattern Position:
|
|
||||||
</div>
|
</div>
|
||||||
{patternUploaded && (
|
);
|
||||||
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
})}
|
||||||
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
</div>
|
||||||
<span className="text-xs font-bold">LOCKED</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
|
|
||||||
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
|
|
||||||
{(localPatternOffset.y / 10).toFixed(1)}mm
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
|
|
||||||
{patternUploaded
|
|
||||||
? "Pattern locked • Drag background to pan"
|
|
||||||
: "Drag pattern to move • Drag background to pan"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zoom Controls Overlay */}
|
{/* Pattern Offset Indicator */}
|
||||||
<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">
|
<div
|
||||||
<Button
|
className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
|
||||||
variant="outline"
|
patternUploaded
|
||||||
size="icon"
|
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
|
||||||
className="w-7 h-7 sm:w-8 sm:h-8"
|
: "bg-white/95 dark:bg-gray-800/95"
|
||||||
onClick={handleCenterPattern}
|
}`}
|
||||||
disabled={!pesData || patternUploaded || isUploading}
|
>
|
||||||
title="Center Pattern in Hoop"
|
<div className="flex items-center justify-between mb-1">
|
||||||
>
|
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||||
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
|
Pattern Position:
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
{patternUploaded && (
|
||||||
variant="outline"
|
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
||||||
size="icon"
|
<LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||||
className="w-7 h-7 sm:w-8 sm:h-8"
|
<span className="text-xs font-bold">LOCKED</span>
|
||||||
onClick={handleZoomIn}
|
</div>
|
||||||
title="Zoom In"
|
)}
|
||||||
>
|
|
||||||
<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
|
|
||||||
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" />
|
|
||||||
</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" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
|
||||||
)}
|
X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
|
||||||
</div>
|
{(localPatternOffset.y / 10).toFixed(1)}mm
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
|
||||||
|
{patternUploaded
|
||||||
|
? "Pattern locked • Drag background to pan"
|
||||||
|
: "Drag pattern to move • Drag background to pan"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
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"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
|
||||||
|
</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"
|
||||||
|
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"
|
||||||
|
onClick={handleZoomReset}
|
||||||
|
title="Reset Zoom"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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,11 +1,4 @@
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
|
|
||||||
interface PatternInfoProps {
|
interface PatternInfoProps {
|
||||||
pesData: PesPatternData;
|
pesData: PesPatternData;
|
||||||
|
|
@ -18,156 +11,94 @@ export function PatternInfo({
|
||||||
}: PatternInfoProps) {
|
}: PatternInfoProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TooltipProvider>
|
<div className="grid grid-cols-3 gap-2 text-xs mb-2">
|
||||||
<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">
|
||||||
<Tooltip>
|
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
|
||||||
<TooltipTrigger asChild>
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded cursor-help">
|
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{" "}
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">
|
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||||
Size
|
</span>
|
||||||
</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
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{pesData.penStitches?.stitches.length.toLocaleString() ||
|
|
||||||
pesData.stitchCount.toLocaleString()}
|
|
||||||
{pesData.penStitches &&
|
|
||||||
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)"
|
|
||||||
>
|
|
||||||
({pesData.stitchCount.toLocaleString()})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{showThreadBlocks
|
|
||||||
? `${pesData.uniqueColors.length} / ${pesData.threads.length}`
|
|
||||||
: 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>
|
</div>
|
||||||
</TooltipProvider>
|
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
<Separator className="mb-3" />
|
Stitches
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{pesData.penStitches?.stitches.length.toLocaleString() ||
|
||||||
|
pesData.stitchCount.toLocaleString()}
|
||||||
|
{pesData.penStitches &&
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
({pesData.stitchCount.toLocaleString()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
|
{showThreadBlocks ? "Colors / Blocks" : "Colors"}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{showThreadBlocks
|
||||||
|
? `${pesData.uniqueColors.length} / ${pesData.threads.length}`
|
||||||
|
: pesData.uniqueColors.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
Colors:
|
Colors:
|
||||||
</span>
|
</span>
|
||||||
<TooltipProvider>
|
<div className="flex gap-1">
|
||||||
<div className="flex gap-1">
|
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
|
||||||
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
|
// Primary metadata: brand and catalog number
|
||||||
// Primary metadata: brand and catalog number
|
const primaryMetadata = [
|
||||||
const primaryMetadata = [
|
color.brand,
|
||||||
color.brand,
|
color.catalogNumber ? `#${color.catalogNumber}` : null,
|
||||||
color.catalogNumber ? `#${color.catalogNumber}` : null,
|
]
|
||||||
]
|
.filter(Boolean)
|
||||||
.filter(Boolean)
|
.join(" ");
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
// Secondary metadata: chart and description
|
// Secondary metadata: chart and description
|
||||||
const secondaryMetadata = [color.chart, color.description]
|
const secondaryMetadata = [color.chart, color.description]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const metadata = [primaryMetadata, secondaryMetadata]
|
const metadata = [primaryMetadata, secondaryMetadata]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" • ");
|
.join(" • ");
|
||||||
|
|
||||||
// Show which thread blocks use this color in PatternSummaryCard
|
// Show which thread blocks use this color in PatternSummaryCard
|
||||||
const threadNumbers = color.threadIndices
|
const threadNumbers = color.threadIndices
|
||||||
.map((i) => i + 1)
|
.map((i) => i + 1)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
const tooltipText = showThreadBlocks
|
const tooltipText = showThreadBlocks
|
||||||
? metadata
|
? metadata
|
||||||
? `Color ${idx + 1}: ${color.hex} - ${metadata}`
|
? `Color ${idx + 1}: ${color.hex} - ${metadata}`
|
||||||
: `Color ${idx + 1}: ${color.hex}`
|
: `Color ${idx + 1}: ${color.hex}`
|
||||||
: metadata
|
: metadata
|
||||||
? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
|
? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
|
||||||
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
|
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={idx}>
|
<div
|
||||||
<TooltipTrigger asChild>
|
key={idx}
|
||||||
<div
|
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 }}
|
||||||
style={{ backgroundColor: color.hex }}
|
title={tooltipText}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
);
|
||||||
<TooltipContent className="max-w-xs">
|
})}
|
||||||
<p className="text-xs whitespace-pre-line">{tooltipText}</p>
|
{pesData.uniqueColors.length > 8 && (
|
||||||
</TooltipContent>
|
<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>
|
+{pesData.uniqueColors.length - 8}
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
{pesData.uniqueColors.length > 8 && (
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
71
src/components/PatternPreviewPlaceholder.tsx
Normal file
71
src/components/PatternPreviewPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
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,15 +4,6 @@ import { usePatternStore } from "../stores/usePatternStore";
|
||||||
import { canDeletePattern } from "../utils/machineStateHelpers";
|
import { canDeletePattern } from "../utils/machineStateHelpers";
|
||||||
import { PatternInfo } from "./PatternInfo";
|
import { PatternInfo } from "./PatternInfo";
|
||||||
import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/solid";
|
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() {
|
export function PatternSummaryCard() {
|
||||||
// Machine store
|
// Machine store
|
||||||
|
|
@ -36,45 +27,61 @@ export function PatternSummaryCard() {
|
||||||
|
|
||||||
const canDelete = canDeletePattern(machineStatus);
|
const canDelete = canDeletePattern(machineStatus);
|
||||||
return (
|
return (
|
||||||
<Card className="p-0 gap-0 border-l-4 border-primary-600 dark:border-primary-500">
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-primary-600 dark:border-primary-500">
|
||||||
<CardHeader className="p-4 pb-3">
|
<div className="flex items-start gap-3 mb-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" />
|
||||||
<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">
|
||||||
<div className="flex-1 min-w-0">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
<CardTitle className="text-sm">Active Pattern</CardTitle>
|
Active Pattern
|
||||||
<CardDescription
|
</h3>
|
||||||
className="text-xs truncate"
|
<p
|
||||||
title={currentFileName}
|
className="text-xs text-gray-600 dark:text-gray-400 truncate"
|
||||||
>
|
title={currentFileName}
|
||||||
{currentFileName}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pt-0 pb-4">
|
|
||||||
<PatternInfo pesData={pesData} />
|
|
||||||
|
|
||||||
{canDelete && (
|
|
||||||
<Button
|
|
||||||
onClick={deletePattern}
|
|
||||||
disabled={isDeleting}
|
|
||||||
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 ? (
|
{currentFileName}
|
||||||
<>
|
</p>
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
</div>
|
||||||
Deleting...
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
<PatternInfo pesData={pesData} />
|
||||||
<>
|
|
||||||
<TrashIcon className="w-3 h-3" />
|
{canDelete && (
|
||||||
Delete Pattern
|
<button
|
||||||
</>
|
onClick={deletePattern}
|
||||||
)}
|
disabled={isDeleting}
|
||||||
</Button>
|
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"
|
||||||
)}
|
>
|
||||||
</CardContent>
|
{isDeleting ? (
|
||||||
</Card>
|
<>
|
||||||
|
<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>
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TrashIcon className="w-3 h-3" />
|
||||||
|
Delete Pattern
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
CircleStackIcon,
|
CircleStackIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
|
CheckBadgeIcon,
|
||||||
|
ClockIcon,
|
||||||
|
PauseCircleIcon,
|
||||||
|
ExclamationCircleIcon,
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
|
|
@ -15,17 +19,9 @@ import {
|
||||||
canStartSewing,
|
canStartSewing,
|
||||||
canStartMaskTrace,
|
canStartMaskTrace,
|
||||||
canResumeSewing,
|
canResumeSewing,
|
||||||
|
getStateVisualInfo,
|
||||||
} from "../utils/machineStateHelpers";
|
} from "../utils/machineStateHelpers";
|
||||||
import { calculatePatternTime } from "../utils/timeCalculation";
|
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() {
|
export function ProgressMonitor() {
|
||||||
// Machine store
|
// Machine store
|
||||||
|
|
@ -59,6 +55,8 @@ export function ProgressMonitor() {
|
||||||
const isMaskTraceComplete =
|
const isMaskTraceComplete =
|
||||||
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
|
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
|
||||||
|
|
||||||
|
const stateVisual = getStateVisualInfo(machineStatus);
|
||||||
|
|
||||||
// Use PEN stitch count as fallback when machine reports 0 total stitches
|
// Use PEN stitch count as fallback when machine reports 0 total stitches
|
||||||
const totalStitches = patternInfo
|
const totalStitches = patternInfo
|
||||||
? patternInfo.totalStitches === 0 && pesData?.penStitches
|
? patternInfo.totalStitches === 0 && pesData?.penStitches
|
||||||
|
|
@ -160,263 +158,325 @@ export function ProgressMonitor() {
|
||||||
return () => window.removeEventListener("resize", checkScrollable);
|
return () => window.removeEventListener("resize", checkScrollable);
|
||||||
}, [colorBlocks]);
|
}, [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 (
|
return (
|
||||||
<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">
|
<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">
|
||||||
<CardHeader className="p-4 pb-3">
|
<div className="flex items-start gap-3 mb-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" />
|
||||||
<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">
|
||||||
<div className="flex-1 min-w-0">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
<CardTitle className="text-sm">Sewing Progress</CardTitle>
|
Sewing Progress
|
||||||
{sewingProgress && (
|
</h3>
|
||||||
<CardDescription className="text-xs">
|
{sewingProgress && (
|
||||||
{progressPercent.toFixed(1)}% complete
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
</CardDescription>
|
{progressPercent.toFixed(1)}% complete
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pattern Info */}
|
||||||
|
{patternInfo && (
|
||||||
|
<div className="grid grid-cols-3 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">
|
||||||
|
Total Stitches
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{totalStitches.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
|
Total Time
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{totalMinutes} min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
|
Speed
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{patternInfo.speed} spm
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
)}
|
||||||
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
|
|
||||||
{/* Pattern Info */}
|
{/* Progress Bar */}
|
||||||
{patternInfo && (
|
{sewingProgress && (
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
|
<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}%` }}
|
||||||
|
/>
|
||||||
|
</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">
|
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
Total Stitches
|
Current Stitch
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{sewingProgress.currentStitch.toLocaleString()} /{" "}
|
||||||
{totalStitches.toLocaleString()}
|
{totalStitches.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
Total Time
|
Time
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{totalMinutes} min
|
{elapsedMinutes} / {totalMinutes} min
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">
|
|
||||||
Speed
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{patternInfo.speed} spm
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* State Visual Indicator */}
|
||||||
{sewingProgress && (
|
{patternInfo &&
|
||||||
<div className="mb-3">
|
(() => {
|
||||||
<Progress
|
const iconMap = {
|
||||||
value={progressPercent}
|
ready: (
|
||||||
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"
|
<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" />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
return (
|
||||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
<div
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">
|
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}`}
|
||||||
Current Stitch
|
>
|
||||||
</span>
|
<div className="flex-shrink-0">
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
{iconMap[stateVisual.iconName]}
|
||||||
{sewingProgress.currentStitch.toLocaleString()} /{" "}
|
|
||||||
{totalStitches.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
<div className="flex-1">
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">
|
<div className="font-semibold text-xs dark:text-gray-100">
|
||||||
Time
|
{stateVisual.label}
|
||||||
</span>
|
</div>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{elapsedMinutes} / {totalMinutes} min
|
{stateVisual.description}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
{/* Color Blocks */}
|
{/* Color Blocks */}
|
||||||
{colorBlocks.length > 0 && (
|
{colorBlocks.length > 0 && (
|
||||||
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
|
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
|
||||||
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
|
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
|
||||||
Color Blocks
|
Color Blocks
|
||||||
</h4>
|
</h4>
|
||||||
<div className="relative lg:flex-1 lg:min-h-0">
|
<div className="relative lg:flex-1 lg:min-h-0">
|
||||||
<div
|
<div
|
||||||
ref={colorBlocksScrollRef}
|
ref={colorBlocksScrollRef}
|
||||||
onScroll={handleColorBlocksScroll}
|
onScroll={handleColorBlocksScroll}
|
||||||
className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full"
|
className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full"
|
||||||
>
|
>
|
||||||
{colorBlocks.map((block, index) => {
|
{colorBlocks.map((block, index) => {
|
||||||
const isCompleted = currentStitch >= block.endStitch;
|
const isCompleted = currentStitch >= block.endStitch;
|
||||||
const isCurrent = index === currentBlockIndex;
|
const isCurrent = index === currentBlockIndex;
|
||||||
|
|
||||||
// Calculate progress within current block
|
// Calculate progress within current block
|
||||||
let blockProgress = 0;
|
let blockProgress = 0;
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
blockProgress =
|
blockProgress =
|
||||||
((currentStitch - block.startStitch) /
|
((currentStitch - block.startStitch) / block.stitchCount) *
|
||||||
block.stitchCount) *
|
100;
|
||||||
100;
|
} else if (isCompleted) {
|
||||||
} else if (isCompleted) {
|
blockProgress = 100;
|
||||||
blockProgress = 100;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
ref={isCurrent ? currentBlockRef : null}
|
ref={isCurrent ? currentBlockRef : null}
|
||||||
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
|
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
|
||||||
isCompleted
|
isCompleted
|
||||||
? "border-success-600 bg-success-50 dark:bg-success-900/20"
|
? "border-success-600 bg-success-50 dark:bg-success-900/20"
|
||||||
: isCurrent
|
: isCurrent
|
||||||
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
|
? "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-100 dark:bg-gray-800/50 opacity-70"
|
: "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70"
|
||||||
}`}
|
}`}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
|
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{/* Color swatch */}
|
{/* Color swatch */}
|
||||||
<div
|
<div
|
||||||
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
|
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: block.threadHex,
|
backgroundColor: block.threadHex,
|
||||||
}}
|
...(isCurrent && { borderColor: "#9333ea" }),
|
||||||
title={`Thread color: ${block.threadHex}`}
|
}}
|
||||||
aria-label={`Thread color ${block.threadHex}`}
|
title={`Thread color: ${block.threadHex}`}
|
||||||
/>
|
aria-label={`Thread color ${block.threadHex}`}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Thread info */}
|
{/* Thread info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
|
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
|
||||||
Thread {block.colorIndex + 1}
|
Thread {block.colorIndex + 1}
|
||||||
{(block.threadBrand ||
|
{(block.threadBrand ||
|
||||||
block.threadChart ||
|
block.threadChart ||
|
||||||
block.threadDescription ||
|
block.threadDescription ||
|
||||||
block.threadCatalogNumber) && (
|
block.threadCatalogNumber) && (
|
||||||
<span className="font-normal text-gray-600 dark:text-gray-400">
|
<span className="font-normal text-gray-600 dark:text-gray-400">
|
||||||
{" "}
|
{" "}
|
||||||
(
|
(
|
||||||
{(() => {
|
{(() => {
|
||||||
// Primary metadata: brand and catalog number
|
// Primary metadata: brand and catalog number
|
||||||
const primaryMetadata = [
|
const primaryMetadata = [
|
||||||
block.threadBrand,
|
block.threadBrand,
|
||||||
block.threadCatalogNumber
|
block.threadCatalogNumber
|
||||||
? `#${block.threadCatalogNumber}`
|
? `#${block.threadCatalogNumber}`
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
// Secondary metadata: chart and description
|
// Secondary metadata: chart and description
|
||||||
const secondaryMetadata = [
|
const secondaryMetadata = [
|
||||||
block.threadChart,
|
block.threadChart,
|
||||||
block.threadDescription,
|
block.threadDescription,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
return [primaryMetadata, secondaryMetadata]
|
return [primaryMetadata, secondaryMetadata]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" • ");
|
.join(" • ");
|
||||||
})()}
|
})()}
|
||||||
)
|
)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
||||||
{block.stitchCount.toLocaleString()} stitches
|
{block.stitchCount.toLocaleString()} stitches
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status icon */}
|
|
||||||
{isCompleted ? (
|
|
||||||
<CheckCircleIcon
|
|
||||||
className="w-5 h-5 text-success-600 flex-shrink-0"
|
|
||||||
aria-label="Completed"
|
|
||||||
/>
|
|
||||||
) : isCurrent ? (
|
|
||||||
<ArrowRightIcon
|
|
||||||
className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 animate-pulse"
|
|
||||||
aria-label="In progress"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CircleStackIcon
|
|
||||||
className="w-5 h-5 text-gray-400 flex-shrink-0"
|
|
||||||
aria-label="Pending"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar for current block */}
|
{/* Status icon */}
|
||||||
{isCurrent && (
|
{isCompleted ? (
|
||||||
<Progress
|
<CheckCircleIcon
|
||||||
value={blockProgress}
|
className="w-5 h-5 text-success-600 flex-shrink-0"
|
||||||
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
|
aria-label="Completed"
|
||||||
aria-label={`${Math.round(blockProgress)}% complete`}
|
/>
|
||||||
|
) : isCurrent ? (
|
||||||
|
<ArrowRightIcon
|
||||||
|
className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse"
|
||||||
|
aria-label="In progress"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CircleStackIcon
|
||||||
|
className="w-5 h-5 text-gray-400 flex-shrink-0"
|
||||||
|
aria-label="Pending"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
{/* Progress bar for current block */}
|
||||||
</div>
|
{isCurrent && (
|
||||||
{/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */}
|
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
{showGradient && (
|
<div
|
||||||
<div className="hidden lg:block absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white dark:from-gray-800 to-transparent pointer-events-none" />
|
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}
|
||||||
|
aria-label={`${Math.round(blockProgress)}% complete`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */}
|
||||||
|
{showGradient && (
|
||||||
|
<div className="hidden lg:block absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white dark:from-gray-800 to-transparent pointer-events-none" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
{/* Resume has highest priority when available */}
|
||||||
|
{canResumeSewing(machineStatus) && (
|
||||||
|
<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"
|
||||||
|
aria-label="Resume sewing the current pattern"
|
||||||
|
>
|
||||||
|
<PlayIcon className="w-3.5 h-3.5" />
|
||||||
|
Resume Sewing
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Start Sewing - primary action, takes more space */}
|
||||||
<div className="flex gap-2 flex-shrink-0">
|
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
|
||||||
{/* Resume has highest priority when available */}
|
<button
|
||||||
{canResumeSewing(machineStatus) && (
|
onClick={startSewing}
|
||||||
<Button
|
disabled={isDeleting}
|
||||||
onClick={resumeSewing}
|
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"
|
||||||
disabled={isDeleting}
|
aria-label="Start sewing the pattern"
|
||||||
className="flex-1"
|
>
|
||||||
aria-label="Resume sewing the current pattern"
|
<PlayIcon className="w-3.5 h-3.5" />
|
||||||
>
|
Start Sewing
|
||||||
<PlayIcon className="w-3.5 h-3.5" />
|
</button>
|
||||||
Resume Sewing
|
)}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Start Sewing - primary action, takes more space */}
|
{/* Start Mask Trace - secondary action */}
|
||||||
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
|
{canStartMaskTrace(machineStatus) && (
|
||||||
<Button
|
<button
|
||||||
onClick={startSewing}
|
onClick={startMaskTrace}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="flex-[2]"
|
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"
|
||||||
aria-label="Start sewing the pattern"
|
aria-label={
|
||||||
>
|
isMaskTraceComplete
|
||||||
<PlayIcon className="w-3.5 h-3.5" />
|
? "Start mask trace again"
|
||||||
Start Sewing
|
: "Start mask trace"
|
||||||
</Button>
|
}
|
||||||
)}
|
>
|
||||||
|
<ArrowPathIcon className="w-3.5 h-3.5" />
|
||||||
{/* Start Mask Trace - secondary action */}
|
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
|
||||||
{canStartMaskTrace(machineStatus) && (
|
</button>
|
||||||
<Button
|
)}
|
||||||
onClick={startMaskTrace}
|
</div>
|
||||||
disabled={isDeleting}
|
</div>
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
aria-label={
|
|
||||||
isMaskTraceComplete
|
|
||||||
? "Start mask trace again"
|
|
||||||
: "Start mask trace"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowPathIcon className="w-3.5 h-3.5" />
|
|
||||||
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface SkeletonLoaderProps {
|
interface SkeletonLoaderProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: "text" | "rect" | "circle";
|
variant?: "text" | "rect" | "circle";
|
||||||
|
|
@ -10,13 +7,18 @@ export function SkeletonLoader({
|
||||||
className = "",
|
className = "",
|
||||||
variant = "rect",
|
variant = "rect",
|
||||||
}: SkeletonLoaderProps) {
|
}: 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 = {
|
const variantClasses = {
|
||||||
text: "h-4 rounded",
|
text: "h-4 rounded",
|
||||||
rect: "rounded-lg",
|
rect: "rounded-lg",
|
||||||
circle: "rounded-full",
|
circle: "rounded-full",
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Skeleton className={cn(variantClasses[variant], className)} />;
|
return (
|
||||||
|
<div className={`${baseClasses} ${variantClasses[variant]} ${className}`} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatternCanvasSkeleton() {
|
export function PatternCanvasSkeleton() {
|
||||||
|
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"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 };
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"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 };
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { type ClassValue, clsx } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
@ -8,12 +8,6 @@
|
||||||
"types": ["vite/client", "web-bluetooth", "node"],
|
"types": ["vite/client", "web-bluetooth", "node"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Path Aliases */
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { dirname, join, resolve } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import type { Plugin } from 'vite'
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
||||||
|
|
||||||
// Read version from package.json
|
// Read version from package.json
|
||||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||||
const appVersion = packageJson.version
|
const appVersion = packageJson.version
|
||||||
|
|
@ -143,11 +141,6 @@ export default defineConfig({
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(appVersion),
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
},
|
},
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue