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": [],
"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": {
"@heroicons/react": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@types/web-bluetooth": "^0.0.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^10.0.0",
"konva": "^10.0.12",
"lucide-react": "^0.562.0",
"pyodide": "^0.29.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-konva": "^19.2.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"update-electron-app": "^3.1.2",
"zustand": "^5.0.9"
},
@ -62,6 +74,7 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "3.7.4",
"shadcn": "^3.6.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",

View file

@ -1,4 +1,108 @@
@import "tailwindcss";
@import "tw-animate-css";
/* ============================================
SHADCN/UI THEME VARIABLES
CSS variables for shadcn/ui components
============================================ */
@layer base {
:root {
/* Background colors */
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
/* Card colors */
--card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%);
/* Popover colors */
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(222.2 84% 4.9%);
/* Primary - Blue (existing brand color) */
--primary: hsl(221.2 83.2% 53.3%); /* blue-600 */
--primary-foreground: hsl(210 40% 98%);
/* Secondary - Orange (existing secondary color) */
--secondary: hsl(24.6 95% 53.1%); /* orange-500 */
--secondary-foreground: hsl(60 9.1% 97.8%);
/* Muted colors */
--muted: hsl(210 40% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
/* Accent - Purple (existing accent color) */
--accent: hsl(262.1 83.3% 57.8%); /* purple-500 */
--accent-foreground: hsl(210 40% 98%);
/* Destructive - Red (existing danger color) */
--destructive: hsl(0 84.2% 60.2%); /* red-500 */
--destructive-foreground: hsl(210 40% 98%);
/* Success - Green (existing success color) */
--success: hsl(142.1 76.2% 36.3%); /* green-600 */
--success-foreground: hsl(210 40% 98%);
/* Warning - Amber (existing warning color) */
--warning: hsl(45.4 93.4% 47.5%); /* amber-500 */
--warning-foreground: hsl(60 9.1% 97.8%);
/* Info - Cyan (existing info color) */
--info: hsl(188.7 85.7% 53.3%); /* cyan-500 */
--info-foreground: hsl(210 40% 98%);
/* Border and input */
--border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%);
--ring: hsl(221.2 83.2% 53.3%); /* matches primary */
/* Radius */
--radius: 0.5rem;
}
@media (prefers-color-scheme: dark) {
:root {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--card: oklch(27.8% 0.033 256.848);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%);
--primary: hsl(217.2 91.2% 59.8%); /* blue-500 lighter for dark */
--primary-foreground: hsl(210 40% 98%);
--secondary: hsl(20.5 90.2% 48.2%); /* orange-600 for dark */
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(263.4 70% 50.4%); /* purple-600 for dark */
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%); /* red-900 */
--destructive-foreground: hsl(210 40% 98%);
--success: hsl(142.1 70.6% 45.3%); /* green-500 for dark */
--success-foreground: hsl(210 40% 98%);
--warning: hsl(47.9 95.8% 53.1%); /* amber-400 for dark */
--warning-foreground: hsl(26 83.3% 14.1%);
--info: hsl(188.7 85.7% 53.3%); /* cyan-500 */
--info-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 37.5%);
--input: hsl(217.2 32.6% 47.5%);
--ring: hsl(224.3 76.3% 48%);
}
}
}
/* ============================================
THEME DEFINITION - Tailwind v4
@ -6,7 +110,28 @@
============================================ */
@theme {
/* PRIMARY - Main brand color (references Blue) */
/* SHADCN/UI COLORS - For bg-primary, bg-destructive, etc. */
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* PRIMARY - Main brand color (references Blue) - For bg-primary-600 style classes */
--color-primary-50: var(--color-blue-50);
--color-primary-100: var(--color-blue-100);
--color-primary-200: var(--color-blue-200);
@ -104,7 +229,7 @@
/* Canvas/Konva-specific colors for embroidery rendering */
--color-canvas-grid: #e0e0e0;
--color-canvas-origin: #888888;
--color-canvas-hoop: #2196F3;
--color-canvas-hoop: #2196f3;
--color-canvas-bounds: #ff0000;
--color-canvas-position: #ff0000;
}
@ -169,7 +294,8 @@
/* Pulse glow effect - uses primary-600 */
@keyframes pulseGlow {
0%, 100% {
0%,
100% {
box-shadow: 0 0 0 0 rgb(37 99 235 / 0.4); /* primary-600 with 40% opacity */
}
50% {

View file

@ -6,7 +6,7 @@ import { useUIStore } from "./stores/useUIStore";
import { AppHeader } from "./components/AppHeader";
import { LeftSidebar } from "./components/LeftSidebar";
import { PatternCanvas } from "./components/PatternCanvas";
import { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder";
import { PatternCanvasPlaceholder } from "./components/PatternCanvasPlaceholder";
import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker";
import "./App.css";
@ -66,7 +66,7 @@ function App() {
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
return (
<div className="h-screen flex flex-col bg-gray-300 dark:bg-gray-900 overflow-hidden">
<div className="h-screen flex flex-col bg-gray-100 dark:bg-gray-900 overflow-hidden">
<AppHeader />
<div className="flex-1 p-4 sm:p-5 lg:p-6 w-full overflow-y-auto lg:overflow-hidden flex flex-col">
@ -76,7 +76,7 @@ function App() {
{/* Right Column - Pattern Preview */}
<div className="flex flex-col lg:overflow-hidden lg:h-full">
{pesData ? <PatternCanvas /> : <PatternPreviewPlaceholder />}
{pesData ? <PatternCanvas /> : <PatternCanvasPlaceholder />}
</div>
</div>

View file

@ -1,9 +1,8 @@
import { useRef, useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore";
import { WorkflowStepper } from "./WorkflowStepper";
import { ErrorPopover } from "./ErrorPopover";
import { ErrorPopoverContent } from "./ErrorPopover";
import { getStateVisualInfo } from "../utils/machineStateHelpers";
import {
CheckCircleIcon,
@ -13,6 +12,16 @@ import {
ArrowPathIcon,
XMarkIcon,
} from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export function AppHeader() {
const {
@ -39,17 +48,12 @@ export function AppHeader() {
})),
);
const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore(
const { pyodideError } = useUIStore(
useShallow((state) => ({
pyodideError: state.pyodideError,
showErrorPopover: state.showErrorPopover,
setErrorPopover: state.setErrorPopover,
})),
);
const errorPopoverRef = useRef<HTMLDivElement>(null);
const errorButtonRef = useRef<HTMLButtonElement>(null);
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = {
@ -62,27 +66,8 @@ export function AppHeader() {
};
const StatusIcon = stateIcons[stateVisual.iconName];
// Close error popover when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
errorPopoverRef.current &&
!errorPopoverRef.current.contains(event.target as Node) &&
errorButtonRef.current &&
!errorButtonRef.current.contains(event.target as Node)
) {
setErrorPopover(false);
}
};
if (showErrorPopover) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [showErrorPopover, setErrorPopover]);
return (
<TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
{/* Machine Connection Status - Responsive width column */}
@ -101,64 +86,95 @@ export function AppHeader() {
Respira
</h1>
{isConnected && machineInfo?.serialNumber && (
<span
className="text-xs text-primary-200 cursor-help"
title={`Serial: ${machineInfo.serialNumber}${
machineInfo.macAddress
? `\nMAC: ${machineInfo.macAddress}`
: ""
}${
machineInfo.totalCount !== undefined
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
: ""
}${
machineInfo.serviceCount !== undefined
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
: ""
}`}
>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-primary-200 cursor-help">
{machineInfo.serialNumber}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="text-sm space-y-1">
<p className="font-semibold">
Serial: {machineInfo.serialNumber}
</p>
{machineInfo.macAddress && (
<p className="text-xs">
MAC: {machineInfo.macAddress}
</p>
)}
{machineInfo.totalCount !== undefined && (
<p className="text-xs">
Total stitches:{" "}
{machineInfo.totalCount.toLocaleString()}
</p>
)}
{machineInfo.serviceCount !== undefined && (
<p className="text-xs">
Stitches since service:{" "}
{machineInfo.serviceCount.toLocaleString()}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
)}
{isPolling && (
<ArrowPathIcon
className="w-3.5 h-3.5 text-primary-200 animate-spin"
title="Auto-refreshing status"
/>
<Tooltip>
<TooltipTrigger asChild>
<div>
<ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Auto-refreshing machine status</p>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
{isConnected ? (
<>
<button
<Button
onClick={disconnect}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-white/10 hover:bg-danger-600 text-primary-100 hover:text-white border border-white/20 hover:border-danger-600 cursor-pointer transition-all flex-shrink-0"
title="Disconnect from machine"
size="sm"
variant="outline"
className="gap-1.5 bg-white/10 hover:bg-danger-600 text-primary-100 hover:text-white border-white/20 hover:border-danger-600 flex-shrink-0"
aria-label="Disconnect from machine"
>
<XMarkIcon className="w-3 h-3" />
Disconnect
</button>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-semibold bg-white/20 text-white border border-white/30 flex-shrink-0">
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="gap-1.5 px-2.5 py-1.5 sm:py-1 text-sm font-semibold bg-white/20 text-white border-white/30 flex-shrink-0 cursor-help"
>
<StatusIcon className="w-3 h-3" />
{machineStatusName}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{stateVisual.description}</p>
</TooltipContent>
</Tooltip>
</>
) : (
<p className="text-xs text-primary-200">Not Connected</p>
)}
{/* Error indicator - always render to prevent layout shift */}
<div className="relative">
<button
ref={errorButtonRef}
onClick={() => setErrorPopover(!showErrorPopover)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="destructive"
className={cn(
"gap-1.5 flex-shrink-0",
machineErrorMessage || pyodideError
? "cursor-pointer animate-pulse hover:animate-none"
: "invisible pointer-events-none"
}`}
title="Click to view error details"
? "animate-pulse hover:animate-none"
: "invisible pointer-events-none",
)}
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
@ -191,19 +207,21 @@ export function AppHeader() {
return "Error";
})()}
</span>
</button>
</Button>
</PopoverTrigger>
{/* Error popover */}
{showErrorPopover && (machineErrorMessage || pyodideError) && (
<ErrorPopover
ref={errorPopoverRef}
machineError={machineError}
{/* Error popover content */}
{(machineErrorMessage || pyodideError) && (
<ErrorPopoverContent
machineError={
machineError != 0xdd ? machineError : undefined
}
isPairingError={isPairingError}
errorMessage={machineErrorMessage}
pyodideError={pyodideError}
/>
)}
</div>
</Popover>
</div>
</div>
</div>
@ -214,5 +232,6 @@ export function AppHeader() {
</div>
</div>
</header>
</TooltipProvider>
);
}

View file

@ -1,5 +1,14 @@
import { useEffect, useState, useCallback } from "react";
import type { BluetoothDevice } from "../types/electron";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function BluetoothDevicePicker() {
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
@ -40,48 +49,17 @@ export function BluetoothDevicePicker() {
setIsScanning(false);
}, []);
// Handle escape key
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancel();
}
},
[handleCancel],
);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
onClick={handleCancel}
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent
className="border-t-4 border-primary-600 dark:border-primary-500"
showCloseButton={false}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="bluetooth-picker-title"
aria-describedby="bluetooth-picker-message"
>
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
<h3
id="bluetooth-picker-title"
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
>
Select Bluetooth Device
</h3>
</div>
<div className="p-6">
<DialogHeader>
<DialogTitle>Select Bluetooth Device</DialogTitle>
<DialogDescription>
{isScanning && devices.length === 0 ? (
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<div className="flex items-center gap-3 py-2">
<svg
className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
xmlns="http://www.w3.org/2000/svg"
@ -95,56 +73,47 @@ export function BluetoothDevicePicker() {
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
/>
</svg>
<span id="bluetooth-picker-message">
Scanning for Bluetooth devices...
</span>
<span>Scanning for Bluetooth devices...</span>
</div>
) : (
<>
<p
id="bluetooth-picker-message"
className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100"
>
{devices.length} device{devices.length !== 1 ? "s" : ""} found.
Select a device to connect:
</p>
`${devices.length} device${devices.length !== 1 ? "s" : ""} found. Select a device to connect:`
)}
</DialogDescription>
</DialogHeader>
{!isScanning && devices.length > 0 && (
<div className="space-y-2">
{devices.map((device) => (
<button
<Button
key={device.deviceId}
onClick={() => handleSelectDevice(device.deviceId)}
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={`Connect to ${device.deviceName}`}
variant="outline"
className="w-full h-auto px-4 py-3 justify-start"
>
<div className="font-semibold text-gray-900 dark:text-white">
{device.deviceName}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
<div className="text-left">
<div className="font-semibold">{device.deviceName}</div>
<div className="text-xs text-muted-foreground mt-1">
{device.deviceId}
</div>
</button>
</div>
</Button>
))}
</div>
</>
)}
</div>
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
<button
onClick={handleCancel}
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Cancel device selection"
>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</button>
</div>
</div>
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

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 {
isOpen: boolean;
@ -21,75 +31,32 @@ export function ConfirmDialog({
onCancel,
variant = "warning",
}: ConfirmDialogProps) {
// Handle escape key
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onCancel();
}
},
[onCancel],
);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
onClick={onCancel}
>
<div
className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === "danger" ? "border-t-4 border-danger-600 dark:border-danger-500" : "border-t-4 border-warning-500 dark:border-warning-600"}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-message"
>
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
<h3
id="dialog-title"
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
>
{title}
</h3>
</div>
<div className="p-6">
<p
id="dialog-message"
className="m-0 leading-relaxed text-gray-900 dark:text-gray-100"
>
{message}
</p>
</div>
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
<button
onClick={onCancel}
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
autoFocus
aria-label="Cancel action"
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent
className={cn(
variant === "danger"
? "px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
: "px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
}
aria-label={`Confirm: ${confirmText}`}
? "border-t-4 border-danger-600 dark:border-danger-500"
: "border-t-4 border-warning-500 dark:border-warning-600",
)}
>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{message}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className={cn(
variant === "danger" &&
"bg-danger-600 hover:bg-danger-700 dark:bg-danger-700 dark:hover:bg-danger-600",
)}
>
{confirmText}
</button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -2,6 +2,9 @@ import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { isBluetoothSupported } from "../utils/bluetoothSupport";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
export function ConnectionPrompt() {
const { connect } = useMachineStore(
@ -12,7 +15,8 @@ export function ConnectionPrompt() {
if (isBluetoothSupported()) {
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<Card className="p-0 gap-0 border-l-4 border-gray-400 dark:border-gray-600">
<CardContent className="p-4 rounded-lg">
<div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
<svg
@ -38,28 +42,27 @@ export function ConnectionPrompt() {
</p>
</div>
</div>
<button
onClick={connect}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer"
>
<Button onClick={connect} className="w-full">
Connect to Machine
</button>
</div>
</Button>
</CardContent>
</Card>
);
}
return (
<div className="bg-warning-50 dark:bg-warning-900/20 p-4 rounded-lg shadow-md border-l-4 border-warning-500 dark:border-warning-600">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<Alert className="bg-warning-50 dark:bg-warning-900/20 border-l-4 border-warning-500 dark:border-warning-600">
<ExclamationTriangleIcon className="h-5 w-5 text-warning-600 dark:text-warning-400" />
<AlertDescription className="space-y-3">
<div>
<h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">
Browser Not Supported
</h3>
<p className="text-sm text-warning-800 dark:text-warning-200 mb-3">
<p className="text-sm text-warning-800 dark:text-warning-200">
Your browser doesn't support Web Bluetooth, which is required to
connect to your embroidery machine.
</p>
</div>
<div className="space-y-2">
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100">
Please try one of these options:
@ -79,8 +82,7 @@ export function ConnectionPrompt() {
</li>
</ul>
</div>
</div>
</div>
</div>
</AlertDescription>
</Alert>
);
}

View file

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

View file

@ -21,6 +21,12 @@ import {
} from "@heroicons/react/24/solid";
import { createFileService } from "../platform";
import type { IFileService } from "../platform/interfaces/IFileService";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export function FileUpload() {
// Machine store
@ -202,12 +208,11 @@ export function FileUpload() {
: "text-gray-600 dark:text-gray-400";
return (
<div
className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}
>
<Card className={cn("p-0 gap-0 border-l-4", borderColor)}>
<CardContent className="p-4 rounded-lg">
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon
className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`}
className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)}
/>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
@ -253,40 +258,41 @@ export function FileUpload() {
className="hidden"
disabled={isLoading || patternUploaded || isUploading}
/>
<label
htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
<Button
asChild={!fileService.hasNativeDialogs()}
onClick={
fileService.hasNativeDialogs()
? () => handleFileChange()
: undefined
}
className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${
isLoading || patternUploaded || isUploading
? "opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white"
: "cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600"
}`}
disabled={isLoading || patternUploaded || isUploading}
variant="outline"
className="flex-[2]"
>
{fileService.hasNativeDialogs() ? (
<>
{isLoading ? (
<>
<svg
className="w-3.5 h-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</>
) : (
<label htmlFor="file-input" className="flex items-center gap-2">
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
@ -301,15 +307,17 @@ export function FileUpload() {
</>
)}
</label>
)}
</Button>
{pesData &&
canUploadPattern(machineStatus) &&
!patternUploaded &&
uploadProgress < 100 && (
<button
<Button
onClick={handleUpload}
disabled={!isConnected || isUploading || !boundsCheck.fits}
className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1"
aria-label={
isUploading
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
@ -318,36 +326,18 @@ export function FileUpload() {
>
{isUploading ? (
<>
<svg
className="w-3.5 h-3.5 animate-spin inline mr-1"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{uploadProgress > 0
? uploadProgress.toFixed(0) + "%"
: "Uploading"}
</>
) : (
<>
<ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
<ArrowUpTrayIcon className="w-3.5 h-3.5" />
Upload
</>
)}
</button>
</Button>
)}
</div>
@ -364,12 +354,7 @@ export function FileUpload() {
{pyodideProgress.toFixed(0)}%
</span>
</div>
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
<div
className="h-full bg-gradient-to-r from-primary-500 via-primary-600 to-primary-700 dark:from-primary-600 dark:via-primary-700 dark:to-primary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
style={{ width: `${pyodideProgress}%` }}
/>
</div>
<Progress value={pyodideProgress} className="h-2.5" />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
{isLoading && !pyodideReady
? "File dialog will open automatically when ready"
@ -393,15 +378,22 @@ export function FileUpload() {
}}
>
{pesData && !canUploadPattern(machineStatus) && (
<div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm">
<Alert className="bg-warning-100 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<AlertDescription className="text-warning-800 dark:text-warning-200 text-sm">
Cannot upload while {getMachineStateCategory(machineStatus)}
</div>
</AlertDescription>
</Alert>
)}
{pesData && boundsCheck.error && (
<div className="bg-danger-100 dark:bg-danger-900/20 text-danger-800 dark:text-danger-200 px-3 py-2 rounded border border-danger-200 dark:border-danger-800 text-sm">
<Alert
variant="destructive"
className="bg-danger-100 dark:bg-danger-900/20 border-danger-200 dark:border-danger-800"
>
<AlertDescription className="text-danger-800 dark:text-danger-200 text-sm">
<strong>Pattern too large:</strong> {boundsCheck.error}
</div>
</AlertDescription>
</Alert>
)}
</div>
@ -417,14 +409,13 @@ export function FileUpload() {
: "Starting..."}
</span>
</div>
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
<div
className="h-full bg-gradient-to-r from-secondary-500 via-secondary-600 to-secondary-700 dark:from-secondary-600 dark:via-secondary-700 dark:to-secondary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
style={{ width: `${uploadProgress}%` }}
<Progress
value={uploadProgress}
className="h-2.5 [&>div]:bg-gradient-to-r [&>div]:from-secondary-500 [&>div]:via-secondary-600 [&>div]:to-secondary-700 dark:[&>div]:from-secondary-600 dark:[&>div]:via-secondary-700 dark:[&>div]:to-secondary-800"
/>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}

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,
CurrentPosition,
} from "./KonvaComponents";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export function PatternCanvas() {
// Machine store
@ -252,27 +260,30 @@ export function PatternCanvas() {
: "text-gray-600 dark:text-gray-400";
return (
<div
className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}
<Card
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
>
<div className="flex items-start gap-3 mb-3 flex-shrink-0">
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Pattern Preview
</h3>
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{pesData ? (
<p className="text-xs text-gray-600 dark:text-gray-400">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} ×{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</p>
<CardDescription className="text-xs">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)}{" "}
×{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)}{" "}
mm
</CardDescription>
) : (
<p className="text-xs text-gray-600 dark:text-gray-400">
<CardDescription className="text-xs">
No pattern loaded
</p>
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col">
<div
className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
ref={containerRef}
@ -475,42 +486,51 @@ export function PatternCanvas() {
{/* Zoom Controls Overlay */}
<div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleCenterPattern}
disabled={!pesData || patternUploaded || isUploading}
title="Center Pattern in Hoop"
>
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
<ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomIn}
title="Zoom In"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
{Math.round(stageScale * 100)}%
</span>
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8"
onClick={handleZoomOut}
title="Zoom Out"
>
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
<button
className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1"
<MinusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="w-7 h-7 sm:w-8 sm:h-8 ml-1"
onClick={handleZoomReset}
title="Reset Zoom"
>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
</button>
<ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5" />
</Button>
</div>
</>
)}
</div>
</div>
</CardContent>
</Card>
);
}

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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
interface PatternInfoProps {
pesData: PesPatternData;
@ -11,15 +18,34 @@ export function PatternInfo({
}: PatternInfoProps) {
return (
<>
<TooltipProvider>
<div className="grid grid-cols-3 gap-2 text-xs mb-2">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded cursor-help">
<span className="text-gray-600 dark:text-gray-400 block">
Size
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(
1,
)}{" "}
x{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(
1,
)}{" "}
mm
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Pattern dimensions (width × height)</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded cursor-help">
<span className="text-gray-600 dark:text-gray-400 block">
Stitches
</span>
@ -27,7 +53,8 @@ export function PatternInfo({
{pesData.penStitches?.stitches.length.toLocaleString() ||
pesData.stitchCount.toLocaleString()}
{pesData.penStitches &&
pesData.penStitches.stitches.length !== pesData.stitchCount && (
pesData.penStitches.stitches.length !==
pesData.stitchCount && (
<span
className="text-gray-500 dark:text-gray-500 font-normal ml-1"
title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
@ -37,7 +64,20 @@ export function PatternInfo({
)}
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">
{pesData.penStitches &&
pesData.penStitches.stitches.length !== pesData.stitchCount
? `Total stitches including lock stitches. Original file had ${pesData.stitchCount.toLocaleString()} stitches.`
: "Total number of stitches in the pattern"}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded cursor-help">
<span className="text-gray-600 dark:text-gray-400 block">
{showThreadBlocks ? "Colors / Blocks" : "Colors"}
</span>
@ -47,12 +87,25 @@ export function PatternInfo({
: pesData.uniqueColors.length}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{showThreadBlocks
? `${pesData.uniqueColors.length} unique ${pesData.uniqueColors.length === 1 ? "color" : "colors"} across ${pesData.threads.length} thread ${pesData.threads.length === 1 ? "block" : "blocks"}`
: `${pesData.uniqueColors.length} unique ${pesData.uniqueColors.length === 1 ? "color" : "colors"} in the pattern`}
</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<Separator className="mb-3" />
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">
Colors:
</span>
<TooltipProvider>
<div className="flex gap-1">
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
// Primary metadata: brand and catalog number
@ -85,20 +138,36 @@ export function PatternInfo({
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
return (
<Tooltip key={idx}>
<TooltipTrigger asChild>
<div
key={idx}
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600 cursor-help"
style={{ backgroundColor: color.hex }}
title={tooltipText}
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs whitespace-pre-line">{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
})}
{pesData.uniqueColors.length > 8 && (
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300 leading-none">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300 leading-none cursor-help">
+{pesData.uniqueColors.length - 8}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{pesData.uniqueColors.length - 8} more{" "}
{pesData.uniqueColors.length - 8 === 1 ? "color" : "colors"}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
</div>
</>
);

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 { PatternInfo } from "./PatternInfo";
import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Loader2 } from "lucide-react";
export function PatternSummaryCard() {
// Machine store
@ -27,51 +36,34 @@ export function PatternSummaryCard() {
const canDelete = canDeletePattern(machineStatus);
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-primary-600 dark:border-primary-500">
<div className="flex items-start gap-3 mb-3">
<Card className="p-0 gap-0 border-l-4 border-primary-600 dark:border-primary-500">
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Active Pattern
</h3>
<p
className="text-xs text-gray-600 dark:text-gray-400 truncate"
<CardTitle className="text-sm">Active Pattern</CardTitle>
<CardDescription
className="text-xs truncate"
title={currentFileName}
>
{currentFileName}
</p>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4">
<PatternInfo pesData={pesData} />
{canDelete && (
<button
<Button
onClick={deletePattern}
disabled={isDeleting}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-danger-50 dark:bg-danger-900/20 text-danger-700 dark:text-danger-300 rounded border border-danger-300 dark:border-danger-700 hover:bg-danger-100 dark:hover:bg-danger-900/30 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
variant="outline"
className="w-full bg-danger-50 dark:bg-danger-900/20 text-danger-700 dark:text-danger-300 border-danger-300 dark:border-danger-700 hover:bg-danger-100 dark:hover:bg-danger-900/30"
>
{isDeleting ? (
<>
<svg
className="w-3 h-3 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Loader2 className="w-3 h-3 animate-spin" />
Deleting...
</>
) : (
@ -80,8 +72,9 @@ export function PatternSummaryCard() {
Delete Pattern
</>
)}
</button>
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -7,10 +7,6 @@ import {
ArrowRightIcon,
CircleStackIcon,
PlayIcon,
CheckBadgeIcon,
ClockIcon,
PauseCircleIcon,
ExclamationCircleIcon,
ChartBarIcon,
ArrowPathIcon,
} from "@heroicons/react/24/solid";
@ -19,9 +15,17 @@ import {
canStartSewing,
canStartMaskTrace,
canResumeSewing,
getStateVisualInfo,
} from "../utils/machineStateHelpers";
import { calculatePatternTime } from "../utils/timeCalculation";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
export function ProgressMonitor() {
// Machine store
@ -55,8 +59,6 @@ export function ProgressMonitor() {
const isMaskTraceComplete =
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
const stateVisual = getStateVisualInfo(machineStatus);
// Use PEN stitch count as fallback when machine reports 0 total stitches
const totalStitches = patternInfo
? patternInfo.totalStitches === 0 && pesData?.penStitches
@ -158,35 +160,22 @@ export function ProgressMonitor() {
return () => window.removeEventListener("resize", checkScrollable);
}, [colorBlocks]);
const stateIndicatorColors = {
idle: "bg-info-50 dark:bg-info-900/20 border-info-600",
info: "bg-info-50 dark:bg-info-900/20 border-info-600",
active: "bg-warning-50 dark:bg-warning-900/20 border-warning-500",
waiting: "bg-warning-50 dark:bg-warning-900/20 border-warning-500",
warning: "bg-warning-50 dark:bg-warning-900/20 border-warning-500",
complete: "bg-success-50 dark:bg-success-900/20 border-success-600",
success: "bg-success-50 dark:bg-success-900/20 border-success-600",
interrupted: "bg-danger-50 dark:bg-danger-900/20 border-danger-600",
error: "bg-danger-50 dark:bg-danger-900/20 border-danger-600",
danger: "bg-danger-50 dark:bg-danger-900/20 border-danger-600",
};
return (
<div className="lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
<div className="flex items-start gap-3 mb-3">
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Sewing Progress
</h3>
<CardTitle className="text-sm">Sewing Progress</CardTitle>
{sewingProgress && (
<p className="text-xs text-gray-600 dark:text-gray-400">
<CardDescription className="text-xs">
{progressPercent.toFixed(1)}% complete
</p>
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
{/* Pattern Info */}
{patternInfo && (
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
@ -220,12 +209,10 @@ export function ProgressMonitor() {
{/* Progress Bar */}
{sewingProgress && (
<div className="mb-3">
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
<div
className="h-full bg-gradient-to-r from-accent-600 to-accent-700 dark:from-accent-600 dark:to-accent-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]"
style={{ width: `${progressPercent}%` }}
<Progress
value={progressPercent}
className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
@ -249,49 +236,6 @@ export function ProgressMonitor() {
</div>
)}
{/* State Visual Indicator */}
{patternInfo &&
(() => {
const iconMap = {
ready: (
<ClockIcon className="w-5 h-5 text-info-600 dark:text-info-400" />
),
active: (
<PlayIcon className="w-5 h-5 text-warning-600 dark:text-warning-400" />
),
waiting: (
<PauseCircleIcon className="w-5 h-5 text-warning-600 dark:text-warning-400" />
),
complete: (
<CheckBadgeIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
),
interrupted: (
<PauseCircleIcon className="w-5 h-5 text-danger-600 dark:text-danger-400" />
),
error: (
<ExclamationCircleIcon className="w-5 h-5 text-danger-600 dark:text-danger-400" />
),
};
return (
<div
className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}
>
<div className="flex-shrink-0">
{iconMap[stateVisual.iconName]}
</div>
<div className="flex-1">
<div className="font-semibold text-xs dark:text-gray-100">
{stateVisual.label}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{stateVisual.description}
</div>
</div>
</div>
);
})()}
{/* Color Blocks */}
{colorBlocks.length > 0 && (
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
@ -312,7 +256,8 @@ export function ProgressMonitor() {
let blockProgress = 0;
if (isCurrent) {
blockProgress =
((currentStitch - block.startStitch) / block.stitchCount) *
((currentStitch - block.startStitch) /
block.stitchCount) *
100;
} else if (isCompleted) {
blockProgress = 100;
@ -326,8 +271,8 @@ export function ProgressMonitor() {
isCompleted
? "border-success-600 bg-success-50 dark:bg-success-900/20"
: isCurrent
? "border-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow"
: "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70"
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
: "border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-800/50 opacity-70"
}`}
role="listitem"
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
@ -338,7 +283,6 @@ export function ProgressMonitor() {
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
style={{
backgroundColor: block.threadHex,
...(isCurrent && { borderColor: "#9333ea" }),
}}
title={`Thread color: ${block.threadHex}`}
aria-label={`Thread color ${block.threadHex}`}
@ -395,7 +339,7 @@ export function ProgressMonitor() {
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse"
className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 animate-pulse"
aria-label="In progress"
/>
) : (
@ -408,17 +352,11 @@ export function ProgressMonitor() {
{/* Progress bar for current block */}
{isCurrent && (
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full"
style={{ width: `${blockProgress}%` }}
role="progressbar"
aria-valuenow={Math.round(blockProgress)}
aria-valuemin={0}
aria-valuemax={100}
<Progress
value={blockProgress}
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
aria-label={`${Math.round(blockProgress)}% complete`}
/>
</div>
)}
</div>
);
@ -436,36 +374,37 @@ export function ProgressMonitor() {
<div className="flex gap-2 flex-shrink-0">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<button
<Button
onClick={resumeSewing}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</button>
</Button>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<button
<Button
onClick={startSewing}
disabled={isDeleting}
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-[2]"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</button>
</Button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<button
<Button
onClick={startMaskTrace}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 sm:py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
variant="outline"
className="flex-1"
aria-label={
isMaskTraceComplete
? "Start mask trace again"
@ -474,9 +413,10 @@ export function ProgressMonitor() {
>
<ArrowPathIcon className="w-3.5 h-3.5" />
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
</button>
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,3 +1,6 @@
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
interface SkeletonLoaderProps {
className?: string;
variant?: "text" | "rect" | "circle";
@ -7,18 +10,13 @@ export function SkeletonLoader({
className = "",
variant = "rect",
}: SkeletonLoaderProps) {
const baseClasses =
"animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]";
const variantClasses = {
text: "h-4 rounded",
rect: "rounded-lg",
circle: "rounded-full",
};
return (
<div className={`${baseClasses} ${variantClasses[variant]} ${className}`} />
);
return <Skeleton className={cn(variantClasses[variant], className)} />;
}
export function PatternCanvasSkeleton() {

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"],
"skipLibCheck": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,

View file

@ -2,11 +2,13 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteStaticCopy } from 'vite-plugin-static-copy'
import tailwindcss from '@tailwindcss/vite'
import { dirname, join } from 'path'
import { dirname, join, resolve } from 'path'
import { fileURLToPath } from 'url'
import { readFileSync } from 'fs'
import type { Plugin } from 'vite'
const __dirname = dirname(fileURLToPath(import.meta.url))
// Read version from package.json
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
const appVersion = packageJson.version
@ -141,6 +143,11 @@ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
plugins: [
react(),
tailwindcss(),