Merge pull request #22 from jhbruhn/feature/shadcn
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions

Feature: shadcn ui
This commit is contained in:
Jan-Henrik Bruhn 2025-12-21 00:25:29 +01:00 committed by GitHub
commit 47c36f92dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 6376 additions and 1635 deletions

View file

@ -8,5 +8,8 @@
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
} },
"enabledMcpjsonServers": [
"shadcn"
]
} }

21
components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/App.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "src/components",
"utils": "src/lib/utils",
"ui": "src/components/ui",
"lib": "src/lib",
"hooks": "src/hooks"
},
"iconLibrary": "lucide"
}

4060
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -23,16 +23,28 @@
}, },
"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"
}, },
@ -62,6 +74,7 @@
"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",

View file

@ -1,4 +1,108 @@
@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
@ -6,7 +110,28 @@
============================================ */ ============================================ */
@theme { @theme {
/* PRIMARY - Main brand color (references Blue) */ /* SHADCN/UI COLORS - For bg-primary, bg-destructive, etc. */
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* PRIMARY - Main brand color (references Blue) - For bg-primary-600 style classes */
--color-primary-50: var(--color-blue-50); --color-primary-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);
@ -104,7 +229,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;
} }
@ -169,7 +294,8 @@
/* Pulse glow effect - uses primary-600 */ /* Pulse glow effect - uses primary-600 */
@keyframes pulseGlow { @keyframes pulseGlow {
0%, 100% { 0%,
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% {

View file

@ -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 { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder"; import { PatternCanvasPlaceholder } from "./components/PatternCanvasPlaceholder";
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-300 dark:bg-gray-900 overflow-hidden"> <div className="h-screen flex flex-col bg-gray-100 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 /> : <PatternPreviewPlaceholder />} {pesData ? <PatternCanvas /> : <PatternCanvasPlaceholder />}
</div> </div>
</div> </div>

View file

@ -1,9 +1,8 @@
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 { ErrorPopover } from "./ErrorPopover"; import { ErrorPopoverContent } from "./ErrorPopover";
import { getStateVisualInfo } from "../utils/machineStateHelpers"; import { getStateVisualInfo } from "../utils/machineStateHelpers";
import { import {
CheckCircleIcon, CheckCircleIcon,
@ -13,6 +12,16 @@ 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 {
@ -39,17 +48,12 @@ export function AppHeader() {
})), })),
); );
const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore( const { pyodideError } = 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 = {
@ -62,157 +66,172 @@ 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 (
<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"> <TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center"> <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">
{/* Machine Connection Status - Responsive width column */} <div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
<div className="flex items-center gap-3 w-full lg:w-[280px]"> {/* Machine Connection Status - Responsive width column */}
<div <div className="flex items-center gap-3 w-full lg:w-[280px]">
className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50" <div
style={{ visibility: isConnected ? "visible" : "hidden" }} className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50"
></div> style={{ visibility: isConnected ? "visible" : "hidden" }}
<div ></div>
className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" <div
style={{ visibility: !isConnected ? "visible" : "hidden" }} className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5"
></div> style={{ visibility: !isConnected ? "visible" : "hidden" }}
<div className="flex-1 min-w-0"> ></div>
<div className="flex items-center gap-2"> <div className="flex-1 min-w-0">
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight"> <div className="flex items-center gap-2">
Respira <h1 className="text-lg lg:text-xl font-bold text-white leading-tight">
</h1> Respira
{isConnected && machineInfo?.serialNumber && ( </h1>
<span {isConnected && machineInfo?.serialNumber && (
className="text-xs text-primary-200 cursor-help" <Tooltip>
title={`Serial: ${machineInfo.serialNumber}${ <TooltipTrigger asChild>
machineInfo.macAddress <span className="text-xs text-primary-200 cursor-help">
? `\nMAC: ${machineInfo.macAddress}` {machineInfo.serialNumber}
: "" </span>
}${ </TooltipTrigger>
machineInfo.totalCount !== undefined <TooltipContent className="max-w-xs">
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}` <div className="text-sm space-y-1">
: "" <p className="font-semibold">
}${ Serial: {machineInfo.serialNumber}
machineInfo.serviceCount !== undefined </p>
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}` {machineInfo.macAddress && (
: "" <p className="text-xs">
}`} MAC: {machineInfo.macAddress}
> </p>
{machineInfo.serialNumber} )}
</span> {machineInfo.totalCount !== undefined && (
)} <p className="text-xs">
{isPolling && ( Total stitches:{" "}
<ArrowPathIcon {machineInfo.totalCount.toLocaleString()}
className="w-3.5 h-3.5 text-primary-200 animate-spin" </p>
title="Auto-refreshing status" )}
/> {machineInfo.serviceCount !== undefined && (
)} <p className="text-xs">
</div> Stitches since service:{" "}
<div className="flex items-center gap-2 mt-1 min-h-[32px]"> {machineInfo.serviceCount.toLocaleString()}
{isConnected ? ( </p>
<> )}
<button </div>
onClick={disconnect} </TooltipContent>
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" </Tooltip>
title="Disconnect from machine"
aria-label="Disconnect from machine"
>
<XMarkIcon className="w-3 h-3" />
Disconnect
</button>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0">
<StatusIcon className="w-3 h-3" />
{machineStatusName}
</span>
</>
) : (
<p className="text-xs text-primary-200">Not Connected</p>
)}
{/* Error indicator - always render to prevent layout shift */}
<div className="relative">
<button
ref={errorButtonRef}
onClick={() => setErrorPopover(!showErrorPopover)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${
machineErrorMessage || pyodideError
? "cursor-pointer animate-pulse hover:animate-none"
: "invisible pointer-events-none"
}`}
title="Click to view error details"
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{(() => {
if (pyodideError) return "Python Error";
if (isPairingError) return "Pairing Required";
const errorMsg = machineErrorMessage || "";
// Categorize by error message content
if (
errorMsg.toLowerCase().includes("bluetooth") ||
errorMsg.toLowerCase().includes("connection")
) {
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>
{/* Error popover */}
{showErrorPopover && (machineErrorMessage || pyodideError) && (
<ErrorPopover
ref={errorPopoverRef}
machineError={machineError}
isPairingError={isPairingError}
errorMessage={machineErrorMessage}
pyodideError={pyodideError}
/>
)} )}
{isPolling && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Auto-refreshing machine status</p>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
{isConnected ? (
<>
<Button
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 */}
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="destructive"
className={cn(
"gap-1.5 flex-shrink-0",
machineErrorMessage || pyodideError
? "animate-pulse hover:animate-none"
: "invisible pointer-events-none",
)}
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{(() => {
if (pyodideError) return "Python Error";
if (isPairingError) return "Pairing Required";
const errorMsg = machineErrorMessage || "";
// Categorize by error message content
if (
errorMsg.toLowerCase().includes("bluetooth") ||
errorMsg.toLowerCase().includes("connection")
) {
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}
errorMessage={machineErrorMessage}
pyodideError={pyodideError}
/>
)}
</Popover>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Workflow Stepper - Flexible width column */} {/* Workflow Stepper - Flexible width column */}
<div> <div>
<WorkflowStepper /> <WorkflowStepper />
</div>
</div> </div>
</div> </header>
</header> </TooltipProvider>
); );
} }

View file

@ -1,5 +1,14 @@
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[]>([]);
@ -40,111 +49,71 @@ 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 (
<div <Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" <DialogContent
onClick={handleCancel} className="border-t-4 border-primary-600 dark:border-primary-500"
> showCloseButton={false}
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="bluetooth-picker-title"
aria-describedby="bluetooth-picker-message"
> >
<div className="p-6 border-b border-gray-300 dark:border-gray-600"> <DialogHeader>
<h3 <DialogTitle>Select Bluetooth Device</DialogTitle>
id="bluetooth-picker-title" <DialogDescription>
className="m-0 text-base lg:text-lg font-semibold dark:text-white" {isScanning && devices.length === 0 ? (
> <div className="flex items-center gap-3 py-2">
Select Bluetooth Device <svg
</h3> className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
</div> xmlns="http://www.w3.org/2000/svg"
<div className="p-6"> fill="none"
{isScanning && devices.length === 0 ? ( viewBox="0 0 24 24"
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300"> >
<svg <circle
className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400" className="opacity-25"
xmlns="http://www.w3.org/2000/svg" cx="12"
fill="none" cy="12"
viewBox="0 0 24 24" r="10"
> stroke="currentColor"
<circle strokeWidth="4"
className="opacity-25" />
cx="12" <path
cy="12" className="opacity-75"
r="10" fill="currentColor"
stroke="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"
strokeWidth="4" />
></circle> </svg>
<path <span>Scanning for Bluetooth devices...</span>
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span id="bluetooth-picker-message">
Scanning for Bluetooth devices...
</span>
</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>
</> ) : (
)} `${devices.length} device${devices.length !== 1 ? "s" : ""} found. Select a device to connect:`
</div> )}
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600"> </DialogDescription>
<button </DialogHeader>
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" {!isScanning && devices.length > 0 && (
aria-label="Cancel device selection" <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">
<div className="font-semibold">{device.deviceName}</div>
<div className="text-xs text-muted-foreground mt-1">
{device.deviceId}
</div>
</div>
</Button>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel Cancel
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
); );
} }

View file

@ -1,4 +1,14 @@
import { useEffect, useCallback } from "react"; import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@ -21,75 +31,32 @@ 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 (
<div <AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" <AlertDialogContent
onClick={onCancel} className={cn(
> variant === "danger"
<div ? "border-t-4 border-danger-600 dark:border-danger-500"
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"}`} : "border-t-4 border-warning-500 dark:border-warning-600",
onClick={(e) => e.stopPropagation()} )}
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-message"
> >
<div className="p-6 border-b border-gray-300 dark:border-gray-600"> <AlertDialogHeader>
<h3 <AlertDialogTitle>{title}</AlertDialogTitle>
id="dialog-title" <AlertDialogDescription>{message}</AlertDialogDescription>
className="m-0 text-base lg:text-lg font-semibold dark:text-white" </AlertDialogHeader>
> <AlertDialogFooter>
{title} <AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
</h3> <AlertDialogAction
</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={ className={cn(
variant === "danger" variant === "danger" &&
? "px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" "bg-danger-600 hover:bg-danger-700 dark:bg-danger-700 dark:hover:bg-danger-600",
: "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}
</button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
); );
} }

View file

@ -2,6 +2,9 @@ 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(
@ -12,75 +15,74 @@ export function ConnectionPrompt() {
if (isBluetoothSupported()) { if (isBluetoothSupported()) {
return ( return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600"> <Card className="p-0 gap-0 border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3"> <CardContent className="p-4 rounded-lg">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5"> <div className="flex items-start gap-3 mb-3">
<svg <div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
className="w-6 h-6" <svg
fill="none" className="w-6 h-6"
stroke="currentColor" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
> viewBox="0 0 24 24"
<path >
strokeLinecap="round" <path
strokeLinejoin="round" strokeLinecap="round"
strokeWidth={2} strokeLinejoin="round"
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" 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"
</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>
<div className="flex-1 min-w-0"> <Button onClick={connect} className="w-full">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> Connect to Machine
Get Started </Button>
</h3> </CardContent>
<p className="text-xs text-gray-600 dark:text-gray-400"> </Card>
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 (
<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"> <Alert className="bg-warning-50 dark:bg-warning-900/20 border-l-4 border-warning-500 dark:border-warning-600">
<div className="flex items-start gap-3"> <ExclamationTriangleIcon className="h-5 w-5 text-warning-600 dark:text-warning-400" />
<ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" /> <AlertDescription className="space-y-3">
<div className="flex-1 min-w-0"> <div>
<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 mb-3"> <p className="text-sm text-warning-800 dark:text-warning-200">
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> <div className="space-y-2">
</div> <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>
</AlertDescription>
</Alert>
); );
} }

View file

@ -1,95 +1,94 @@
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 ErrorPopoverProps { interface ErrorPopoverContentProps {
machineError?: number; machineError?: number;
isPairingError: boolean; isPairingError: boolean;
errorMessage?: string | null; errorMessage?: string | null;
pyodideError?: string | null; pyodideError?: string | null;
} }
export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>( export function ErrorPopoverContent({
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => { machineError,
const errorDetails = getErrorDetails(machineError); isPairingError,
const isPairingErr = isPairingError; errorMessage,
const errorMsg = pyodideError || errorMessage || ""; pyodideError,
const isInfo = isPairingErr || errorDetails?.isInformational; }: ErrorPopoverContentProps) {
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 (
<div <PopoverContent
ref={ref} className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)}
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn" align="start"
role="dialog" >
aria-label="Error details" <div className="flex items-start gap-3">
> <Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
<div <div className="flex-1">
className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`} <h3 className={cn("text-base font-semibold mb-2", textColor)}>
> {title}
<div className="flex items-start gap-3"> </h3>
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> <p className={cn("text-sm mb-3", descColor)}>
<div className="flex-1"> {errorDetails?.description || errorMsg}
<h3 className={`text-base font-semibold ${textColor} mb-2`}> </p>
{title} {errorDetails?.solutions && errorDetails.solutions.length > 0 && (
</h3> <>
<p className={`text-sm ${descColor} mb-3`}> <h4 className={cn("text-sm font-semibold mb-2", textColor)}>
{errorDetails?.description || errorMsg} {isInfo ? "Steps:" : "How to Fix:"}
</p> </h4>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && ( <ol
<> className={cn(
<h4 className={`text-sm font-semibold ${textColor} mb-2`}> "list-decimal list-inside text-sm space-y-1.5",
{isInfo ? "Steps:" : "How to Fix:"} listColor,
</h4> )}
<ol >
className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`} {errorDetails.solutions.map((solution, index) => (
> <li key={index} className="pl-2">
{errorDetails.solutions.map((solution, index) => ( {solution}
<li key={index} className="pl-2"> </li>
{solution} ))}
</li> </ol>
))} </>
</ol> )}
</> {machineError !== undefined && !errorDetails?.isInformational && (
)} <p className={cn("text-xs mt-3 font-mono", descColor)}>
{machineError !== undefined && !errorDetails?.isInformational && ( Error Code: 0x
<p className={`text-xs ${descColor} mt-3 font-mono`}> {machineError.toString(16).toUpperCase().padStart(2, "0")}
Error Code: 0x </p>
{machineError.toString(16).toUpperCase().padStart(2, "0")} )}
</p>
)}
</div>
</div>
</div> </div>
</div> </div>
); </PopoverContent>
}, );
); }
ErrorPopover.displayName = "ErrorPopover";

View file

@ -21,6 +21,12 @@ 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
@ -202,229 +208,214 @@ export function FileUpload() {
: "text-gray-600 dark:text-gray-400"; : "text-gray-600 dark:text-gray-400";
return ( return (
<div <Card className={cn("p-0 gap-0 border-l-4", borderColor)}>
className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`} <CardContent className="p-4 rounded-lg">
> <div className="flex items-start gap-3 mb-3">
<div className="flex items-start gap-3 mb-3"> <DocumentTextIcon
<DocumentTextIcon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)}
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">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> Pattern File
Pattern File </h3>
</h3> {pesData && displayFileName ? (
{pesData && displayFileName ? ( <p
<p className="text-xs text-gray-600 dark:text-gray-400 truncate"
className="text-xs text-gray-600 dark:text-gray-400 truncate" title={displayFileName}
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}
/>
<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 {displayFileName}
className="opacity-25" </p>
cx="12" ) : (
cy="12" <p className="text-xs text-gray-600 dark:text-gray-400">
r="10" No pattern loaded
stroke="currentColor" </p>
strokeWidth="4" )}
></circle> </div>
<path </div>
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 && {resumeAvailable && resumeFileName && (
canUploadPattern(machineStatus) && <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">
!patternUploaded && <p className="text-xs text-success-800 dark:text-success-200">
uploadProgress < 100 && ( <strong>Cached:</strong> "{resumeFileName}"
<button </p>
onClick={handleUpload} </div>
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 */} {isLoading && <PatternInfoSkeleton />}
{!pyodideReady && pyodideProgress > 0 && (
<div className="mb-3"> {!isLoading && pesData && (
<div className="flex justify-between items-center mb-1.5"> <div className="mb-3">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400"> <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 {isLoading && !pyodideReady
? "Please wait - initializing Python environment..." ? "File dialog will open automatically when ready"
: pyodideLoadingStep || "Initializing Python environment..."} : "This only happens once on first use"}
</span> </p>
<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>
<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> </div>
)} )}
{pesData && boundsCheck.error && ( {/* Error/warning messages with smooth transition - placed after buttons */}
<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"> <div
<strong>Pattern too large:</strong> {boundsCheck.error} className="transition-all duration-200 ease-in-out overflow-hidden"
</div> style={{
)} maxHeight:
</div> 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>
)}
{isUploading && uploadProgress < 100 && ( {pesData && boundsCheck.error && (
<div className="mt-3"> <Alert
<div className="flex justify-between items-center mb-1.5"> variant="destructive"
<span className="text-xs font-medium text-gray-600 dark:text-gray-400"> className="bg-danger-100 dark:bg-danger-900/20 border-danger-200 dark:border-danger-800"
Uploading >
</span> <AlertDescription className="text-danger-800 dark:text-danger-200 text-sm">
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400"> <strong>Pattern too large:</strong> {boundsCheck.error}
{uploadProgress > 0 </AlertDescription>
? uploadProgress.toFixed(1) + "%" </Alert>
: "Starting..."} )}
</span> </div>
</div>
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative"> {isUploading && uploadProgress < 100 && (
<div <div className="mt-3">
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" <div className="flex justify-between items-center mb-1.5">
style={{ width: `${uploadProgress}%` }} <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>
<Progress
value={uploadProgress}
className="h-2.5 [&>div]:bg-gradient-to-r [&>div]:from-secondary-500 [&>div]:via-secondary-600 [&>div]:to-secondary-700 dark:[&>div]:from-secondary-600 dark:[&>div]:via-secondary-700 dark:[&>div]:to-secondary-800"
/> />
</div> </div>
</div> )}
)} </CardContent>
</div> </Card>
); );
} }

View file

@ -1,224 +0,0 @@
import { useState } from "react";
import {
InformationCircleIcon,
CheckCircleIcon,
BoltIcon,
PauseCircleIcon,
ExclamationTriangleIcon,
WifiIcon,
} from "@heroicons/react/24/solid";
import type { MachineInfo } from "../types/machine";
import { MachineStatus } from "../types/machine";
import { ConfirmDialog } from "./ConfirmDialog";
import {
shouldConfirmDisconnect,
getStateVisualInfo,
} from "../utils/machineStateHelpers";
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
interface MachineConnectionProps {
isConnected: boolean;
machineInfo: MachineInfo | null;
machineStatus: MachineStatus;
machineStatusName: string;
machineError: number;
onConnect: () => void;
onDisconnect: () => void;
onRefresh: () => void;
}
export function MachineConnection({
isConnected,
machineInfo,
machineStatus,
machineStatusName,
machineError,
onConnect,
onDisconnect,
}: MachineConnectionProps) {
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
const handleDisconnectClick = () => {
if (shouldConfirmDisconnect(machineStatus)) {
setShowDisconnectConfirm(true);
} else {
onDisconnect();
}
};
const handleConfirmDisconnect = () => {
setShowDisconnectConfirm(false);
onDisconnect();
};
const stateVisual = getStateVisualInfo(machineStatus);
// Map icon names to Heroicons
const stateIcons = {
ready: CheckCircleIcon,
active: BoltIcon,
waiting: PauseCircleIcon,
complete: CheckCircleIcon,
interrupted: PauseCircleIcon,
error: ExclamationTriangleIcon,
};
const statusBadgeColors = {
idle: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
info: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
active:
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
waiting:
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
warning:
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
complete:
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
success:
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
interrupted:
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
error:
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
danger:
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
};
// Only show error info when connected AND there's an actual error
const errorInfo =
isConnected && hasError(machineError)
? getErrorDetails(machineError)
: null;
return (
<>
{!isConnected ? (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3">
<WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Machine
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">
Ready to connect
</p>
</div>
</div>
<button
onClick={onConnect}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer"
>
Connect to Machine
</button>
</div>
) : (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-success-600 dark:border-success-500">
<div className="flex items-start gap-3 mb-3">
<WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Machine Info
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">
{machineInfo?.modelNumber || "Brother Embroidery Machine"}
</p>
</div>
</div>
{/* Error/Info Display */}
{errorInfo &&
(errorInfo.isInformational ? (
<div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg">
<div className="flex items-start gap-2">
<InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold text-info-900 dark:text-info-200 text-xs">
{errorInfo.title}
</div>
</div>
</div>
</div>
) : (
<div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg">
<div className="flex items-start gap-2">
<span className="text-danger-600 dark:text-danger-400 flex-shrink-0">
</span>
<div className="flex-1 min-w-0">
<div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">
{errorInfo.title}
</div>
<div className="text-xs text-danger-700 dark:text-danger-300 font-mono">
Error Code: 0x
{machineError.toString(16).toUpperCase().padStart(2, "0")}
</div>
</div>
</div>
</div>
))}
{/* Status Badge */}
<div className="mb-3">
<span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">
Status:
</span>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}
>
{(() => {
const Icon = stateIcons[stateVisual.iconName];
return <Icon className="w-3.5 h-3.5" />;
})()}
<span>{machineStatusName}</span>
</span>
</div>
{/* Machine Info */}
{machineInfo && (
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Max Area
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{(machineInfo.maxWidth / 10).toFixed(1)} ×{" "}
{(machineInfo.maxHeight / 10).toFixed(1)} mm
</span>
</div>
{machineInfo.totalCount !== undefined && (
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{machineInfo.totalCount.toLocaleString()}
</span>
</div>
)}
</div>
)}
<button
onClick={handleDisconnectClick}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600 text-xs font-medium transition-colors cursor-pointer"
>
Disconnect
</button>
</div>
)}
<ConfirmDialog
isOpen={showDisconnectConfirm}
title="Confirm Disconnect"
message={`The machine is currently ${machineStatusName.toLowerCase()}. Disconnecting may interrupt the operation. Are you sure you want to disconnect?`}
confirmText="Disconnect Anyway"
cancelText="Cancel"
onConfirm={handleConfirmDisconnect}
onCancel={() => setShowDisconnectConfirm(false)}
variant="danger"
/>
</>
);
}

View file

@ -22,6 +22,14 @@ 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
@ -252,126 +260,101 @@ export function PatternCanvas() {
: "text-gray-600 dark:text-gray-400"; : "text-gray-600 dark:text-gray-400";
return ( return (
<div <Card
className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`} className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
> >
<div className="flex items-start gap-3 mb-3 flex-shrink-0"> <CardHeader className="p-4 pb-3">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> <div className="flex items-start gap-3">
<div className="flex-1 min-w-0"> <PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> <div className="flex-1 min-w-0">
Pattern Preview <CardTitle className="text-sm">Pattern Preview</CardTitle>
</h3> {pesData ? (
{pesData ? ( <CardDescription className="text-xs">
<p className="text-xs text-gray-600 dark:text-gray-400"> {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "}
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} ×{" "} ×{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "}
</p> mm
) : ( </CardDescription>
<p className="text-xs text-gray-600 dark:text-gray-400"> ) : (
No pattern loaded <CardDescription className="text-xs">
</p> No pattern loaded
)} </CardDescription>
)}
</div>
</div> </div>
</div> </CardHeader>
<div <CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col">
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" <div
ref={containerRef} 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}
{containerSize.width > 0 && ( >
<Stage {containerSize.width > 0 && (
width={containerSize.width} <Stage
height={containerSize.height} width={containerSize.width}
x={stagePos.x} height={containerSize.height}
y={stagePos.y} x={stagePos.x}
scaleX={stageScale} y={stagePos.y}
scaleY={stageScale} scaleX={stageScale}
draggable scaleY={stageScale}
onWheel={handleWheel} draggable
onDragStart={() => { onWheel={handleWheel}
if (stageRef.current) { onDragStart={() => {
stageRef.current.container().style.cursor = "grabbing"; if (stageRef.current) {
} stageRef.current.container().style.cursor = "grabbing";
}} }
onDragEnd={() => { }}
if (stageRef.current) { onDragEnd={() => {
stageRef.current.container().style.cursor = "grab"; if (stageRef.current) {
} stageRef.current.container().style.cursor = "grab";
}} }
ref={(node) => { }}
stageRef.current = node; ref={(node) => {
if (node) { stageRef.current = node;
node.container().style.cursor = "grab"; if (node) {
} node.container().style.cursor = "grab";
}} }
> }}
{/* Background layer: grid, origin, hoop */} >
<Layer> {/* Background layer: grid, origin, hoop */}
{pesData && ( <Layer>
<> {pesData && (
<Grid <>
gridSize={100} <Grid
bounds={pesData.bounds} gridSize={100}
machineInfo={machineInfo} bounds={pesData.bounds}
/> machineInfo={machineInfo}
<Origin /> />
{machineInfo && <Hoop machineInfo={machineInfo} />} <Origin />
</> {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] => {
const cmd = s.isJump ? 0x10 : 0; // 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 = const colorIndex =
pesData.penStitches.colorBlocks.find( pesData.penStitches.colorBlocks.find(
(b) => i >= b.startStitch && i <= b.endStitch, (b) => i >= b.startStitch && i <= b.endStitch,
@ -379,138 +362,175 @@ 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>
)}
{/* Placeholder overlay when no pattern is loaded */} {/* Current position layer */}
{!pesData && ( <Layer>
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic"> {pesData &&
Load a PES file to preview the pattern pesData.penStitches &&
</div> sewingProgress &&
)} 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>
)}
{/* Pattern info overlays */} {/* Placeholder overlay when no pattern is loaded */}
{pesData && ( {!pesData && (
<> <div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
{/* Thread Legend Overlay */} Load a PES file to preview the pattern
<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]"> </div>
<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 {/* Pattern info overlays */}
const secondaryMetadata = [color.chart, color.description] {pesData && (
.filter(Boolean) <>
.join(" "); {/* Thread Legend Overlay */}
<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(" ");
return ( // Secondary metadata: chart and description
<div const secondaryMetadata = [color.chart, color.description]
key={idx} .filter(Boolean)
className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0" .join(" ");
>
return (
<div <div
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" key={idx}
style={{ backgroundColor: color.hex }} className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
/> >
<div className="flex-1 min-w-0"> <div
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100"> 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"
Color {idx + 1} style={{ backgroundColor: color.hex }}
</div> />
{(primaryMetadata || secondaryMetadata) && ( <div className="flex-1 min-w-0">
<div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words"> <div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
{primaryMetadata} Color {idx + 1}
{primaryMetadata && secondaryMetadata && (
<span className="mx-1"></span>
)}
{secondaryMetadata}
</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>
</div> );
); })}
})} </div>
</div>
{/* Pattern Offset Indicator */} {/* Pattern Offset Indicator */}
<div <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 ${ 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 patternUploaded
? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600" ? "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" : "bg-white/95 dark:bg-gray-800/95"
}`} }`}
> >
<div className="flex items-center justify-between mb-1"> <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"> <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Pattern Position: Pattern Position:
</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" />
<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>
{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" />
<span className="text-xs font-bold">LOCKED</span>
</div>
)}
</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 */} {/* 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"> <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 <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" variant="outline"
onClick={handleCenterPattern} size="icon"
disabled={!pesData || patternUploaded || isUploading} className="w-7 h-7 sm:w-8 sm:h-8"
title="Center Pattern in Hoop" onClick={handleCenterPattern}
> disabled={!pesData || patternUploaded || isUploading}
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" /> title="Center Pattern in Hoop"
</button> >
<button <ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" </Button>
onClick={handleZoomIn} <Button
title="Zoom In" variant="outline"
> size="icon"
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" /> className="w-7 h-7 sm:w-8 sm:h-8"
</button> onClick={handleZoomIn}
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none"> title="Zoom In"
{Math.round(stageScale * 100)}% >
</span> <PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
<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" <span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
onClick={handleZoomOut} {Math.round(stageScale * 100)}%
title="Zoom Out" </span>
> <Button
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" /> variant="outline"
</button> size="icon"
<button className="w-7 h-7 sm:w-8 sm:h-8"
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={handleZoomOut}
onClick={handleZoomReset} title="Zoom Out"
title="Reset Zoom" >
> <MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" /> </Button>
</button> <Button
</div> variant="outline"
</> size="icon"
)} className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
</div> onClick={handleZoomReset}
</div> title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
</div>
</>
)}
</div>
</CardContent>
</Card>
); );
} }

View file

@ -0,0 +1,94 @@
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription,
} from "@/components/ui/card";
import { PhotoIcon } from "@heroicons/react/24/solid";
export function PatternCanvasPlaceholder() {
return (
<Card
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 border-gray-400 dark:border-gray-600`}
>
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<PhotoIcon
className={`w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5`}
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle>
<CardDescription className="text-xs">
No pattern loaded
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6 flex-1 flex flex-col">
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
{/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10">
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
</div>
<div className="text-center relative z-10">
<div className="relative inline-block mb-6">
<svg
className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<svg
className="w-5 h-5 text-primary-600 dark:text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</div>
</div>
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
No Pattern Loaded
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
Connect to your machine and choose a PES embroidery file to see
your design preview
</p>
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-primary-400 dark:bg-primary-500 rounded-full"></div>
<span>Drag to Position</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-success-400 dark:bg-success-500 rounded-full"></div>
<span>Zoom & Pan</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-accent-400 dark:bg-accent-500 rounded-full"></div>
<span>Real-time Preview</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,4 +1,11 @@
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;
@ -11,94 +18,156 @@ export function PatternInfo({
}: PatternInfoProps) { }: PatternInfoProps) {
return ( return (
<> <>
<div className="grid grid-cols-3 gap-2 text-xs mb-2"> <TooltipProvider>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="grid grid-cols-3 gap-2 text-xs mb-2">
<span className="text-gray-600 dark:text-gray-400 block">Size</span> <Tooltip>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <TooltipTrigger asChild>
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{" "} <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded cursor-help">
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm <span className="text-gray-600 dark:text-gray-400 block">
</span> Size
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<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>
)} <span className="font-semibold text-gray-900 dark:text-gray-100">
</span> {((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>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> </TooltipProvider>
<span className="text-gray-600 dark:text-gray-400 block">
{showThreadBlocks ? "Colors / Blocks" : "Colors"} <Separator className="mb-3" />
</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>
<div className="flex gap-1"> <TooltipProvider>
{pesData.uniqueColors.slice(0, 8).map((color, idx) => { <div className="flex gap-1">
// Primary metadata: brand and catalog number {pesData.uniqueColors.slice(0, 8).map((color, idx) => {
const primaryMetadata = [ // Primary metadata: brand and catalog number
color.brand, const primaryMetadata = [
color.catalogNumber ? `#${color.catalogNumber}` : null, color.brand,
] color.catalogNumber ? `#${color.catalogNumber}` : null,
.filter(Boolean) ]
.join(" "); .filter(Boolean)
.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 (
<div <Tooltip key={idx}>
key={idx} <TooltipTrigger asChild>
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600" <div
style={{ backgroundColor: color.hex }} className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600 cursor-help"
title={tooltipText} style={{ backgroundColor: color.hex }}
/> />
); </TooltipTrigger>
})} <TooltipContent className="max-w-xs">
{pesData.uniqueColors.length > 8 && ( <p className="text-xs whitespace-pre-line">{tooltipText}</p>
<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"> </TooltipContent>
+{pesData.uniqueColors.length - 8} </Tooltip>
</div> );
)} })}
</div> {pesData.uniqueColors.length > 8 && (
<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>
</> </>
); );

View file

@ -1,71 +0,0 @@
export function PatternPreviewPlaceholder() {
return (
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">
Pattern Preview
</h2>
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
{/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10">
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
</div>
<div className="text-center relative z-10">
<div className="relative inline-block mb-6">
<svg
className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<svg
className="w-5 h-5 text-primary-600 dark:text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</div>
</div>
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
No Pattern Loaded
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
Connect to your machine and choose a PES embroidery file to see your
design preview
</p>
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-primary-400 dark:bg-primary-500 rounded-full"></div>
<span>Drag to Position</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-success-400 dark:bg-success-500 rounded-full"></div>
<span>Zoom & Pan</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-accent-400 dark:bg-accent-500 rounded-full"></div>
<span>Real-time Preview</span>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -4,6 +4,15 @@ 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
@ -27,61 +36,45 @@ export function PatternSummaryCard() {
const canDelete = canDeletePattern(machineStatus); const canDelete = canDeletePattern(machineStatus);
return ( return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-primary-600 dark:border-primary-500"> <Card className="p-0 gap-0 border-l-4 border-primary-600 dark:border-primary-500">
<div className="flex items-start gap-3 mb-3"> <CardHeader className="p-4 pb-3">
<DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" /> <div className="flex items-start gap-3">
<div className="flex-1 min-w-0"> <DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> <div className="flex-1 min-w-0">
Active Pattern <CardTitle className="text-sm">Active Pattern</CardTitle>
</h3> <CardDescription
<p className="text-xs truncate"
className="text-xs text-gray-600 dark:text-gray-400 truncate" title={currentFileName}
title={currentFileName} >
> {currentFileName}
{currentFileName} </CardDescription>
</p> </div>
</div> </div>
</div> </CardHeader>
<CardContent className="px-4 pt-0 pb-4">
<PatternInfo pesData={pesData} />
<PatternInfo pesData={pesData} /> {canDelete && (
<Button
{canDelete && ( onClick={deletePattern}
<button disabled={isDeleting}
onClick={deletePattern} variant="outline"
disabled={isDeleting} 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"
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" >
> {isDeleting ? (
{isDeleting ? ( <>
<> <Loader2 className="w-3 h-3 animate-spin" />
<svg Deleting...
className="w-3 h-3 animate-spin" </>
fill="none" ) : (
viewBox="0 0 24 24" <>
> <TrashIcon className="w-3 h-3" />
<circle Delete Pattern
className="opacity-25" </>
cx="12" )}
cy="12" </Button>
r="10" )}
stroke="currentColor" </CardContent>
strokeWidth="4" </Card>
></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>
); );
} }

View file

@ -7,10 +7,6 @@ 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";
@ -19,9 +15,17 @@ 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
@ -55,8 +59,6 @@ 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
@ -158,325 +160,263 @@ 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 (
<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"> <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="flex items-start gap-3 mb-3"> <CardHeader className="p-4 pb-3">
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" /> <div className="flex items-start gap-3">
<div className="flex-1 min-w-0"> <ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1"> <div className="flex-1 min-w-0">
Sewing Progress <CardTitle className="text-sm">Sewing Progress</CardTitle>
</h3> {sewingProgress && (
{sewingProgress && ( <CardDescription className="text-xs">
<p className="text-xs text-gray-600 dark:text-gray-400"> {progressPercent.toFixed(1)}% complete
{progressPercent.toFixed(1)}% complete </CardDescription>
</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">
{/* Progress Bar */} {/* Pattern Info */}
{sewingProgress && ( {patternInfo && (
<div className="mb-3"> <div className="grid grid-cols-3 gap-2 text-xs 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">
Current Stitch Total Stitches
</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">
Time Total 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">
{elapsedMinutes} / {totalMinutes} min {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> )}
)}
{/* State Visual Indicator */} {/* Progress Bar */}
{patternInfo && {sewingProgress && (
(() => { <div className="mb-3">
const iconMap = { <Progress
ready: ( value={progressPercent}
<ClockIcon className="w-5 h-5 text-info-600 dark:text-info-400" /> 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"
), />
active: (
<PlayIcon className="w-5 h-5 text-warning-600 dark:text-warning-400" />
),
waiting: (
<PauseCircleIcon className="w-5 h-5 text-warning-600 dark:text-warning-400" />
),
complete: (
<CheckBadgeIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
),
interrupted: (
<PauseCircleIcon className="w-5 h-5 text-danger-600 dark:text-danger-400" />
),
error: (
<ExclamationCircleIcon className="w-5 h-5 text-danger-600 dark:text-danger-400" />
),
};
return ( <div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
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}`} <span className="text-gray-600 dark:text-gray-400 block">
> Current Stitch
<div className="flex-shrink-0"> </span>
{iconMap[stateVisual.iconName]} <span className="font-semibold text-gray-900 dark:text-gray-100">
{sewingProgress.currentStitch.toLocaleString()} /{" "}
{totalStitches.toLocaleString()}
</span>
</div> </div>
<div className="flex-1"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<div className="font-semibold text-xs dark:text-gray-100"> <span className="text-gray-600 dark:text-gray-400 block">
{stateVisual.label} Time
</div> </span>
<div className="text-xs text-gray-600 dark:text-gray-400"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{stateVisual.description} {elapsedMinutes} / {totalMinutes} min
</div> </span>
</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) / block.stitchCount) * ((currentStitch - block.startStitch) /
100; block.stitchCount) *
} else if (isCompleted) { 100;
blockProgress = 100; } else if (isCompleted) {
} 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-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow" ? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
: "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70" : "border-gray-200 dark:border-gray-600 bg-gray-100 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}`}
title={`Thread color: ${block.threadHex}`} aria-label={`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>
{/* Status icon */} {/* Progress bar for current block */}
{isCompleted ? ( {isCurrent && (
<CheckCircleIcon <Progress
className="w-5 h-5 text-success-600 flex-shrink-0" value={blockProgress}
aria-label="Completed" className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
/> 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 */} })}
{isCurrent && ( </div>
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> {/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */}
<div {showGradient && (
className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full" <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" />
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>
)}
{/* 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"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</Button>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<Button
onClick={startSewing}
disabled={isDeleting}
className="flex-[2]"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</Button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<Button
onClick={startMaskTrace}
disabled={isDeleting}
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> </div>
)} </CardContent>
</Card>
{/* 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>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<button
onClick={startSewing}
disabled={isDeleting}
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<button
onClick={startMaskTrace}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
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>
</div>
); );
} }

View file

@ -1,3 +1,6 @@
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";
@ -7,18 +10,13 @@ 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 ( return <Skeleton className={cn(variantClasses[variant], className)} />;
<div className={`${baseClasses} ${variantClasses[variant]} ${className}`} />
);
} }
export function PatternCanvasSkeleton() { export function PatternCanvasSkeleton() {

View file

@ -0,0 +1,155 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View file

@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View file

@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants };

View file

@ -0,0 +1,63 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants };

View file

@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View file

@ -0,0 +1,141 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View file

@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View file

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View file

@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -0,0 +1,59 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -8,6 +8,12 @@
"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,

View file

@ -2,11 +2,13 @@ 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 } from 'path' import { dirname, join, resolve } 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
@ -141,6 +143,11 @@ 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(),