mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
Merge pull request #22 from jhbruhn/feature/shadcn
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
Feature: shadcn ui
This commit is contained in:
commit
47c36f92dd
34 changed files with 6376 additions and 1635 deletions
|
|
@ -8,5 +8,8 @@
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
}
|
},
|
||||||
|
"enabledMcpjsonServers": [
|
||||||
|
"shadcn"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/App.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "src/components",
|
||||||
|
"utils": "src/lib/utils",
|
||||||
|
"ui": "src/components/ui",
|
||||||
|
"lib": "src/lib",
|
||||||
|
"hooks": "src/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
4060
package-lock.json
generated
4060
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -23,16 +23,28 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
||||||
|
|
|
||||||
132
src/App.css
132
src/App.css
|
|
@ -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% {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
InformationCircleIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
BoltIcon,
|
|
||||||
PauseCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
WifiIcon,
|
|
||||||
} from "@heroicons/react/24/solid";
|
|
||||||
import type { MachineInfo } from "../types/machine";
|
|
||||||
import { MachineStatus } from "../types/machine";
|
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
|
||||||
import {
|
|
||||||
shouldConfirmDisconnect,
|
|
||||||
getStateVisualInfo,
|
|
||||||
} from "../utils/machineStateHelpers";
|
|
||||||
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
|
|
||||||
|
|
||||||
interface MachineConnectionProps {
|
|
||||||
isConnected: boolean;
|
|
||||||
machineInfo: MachineInfo | null;
|
|
||||||
machineStatus: MachineStatus;
|
|
||||||
machineStatusName: string;
|
|
||||||
machineError: number;
|
|
||||||
onConnect: () => void;
|
|
||||||
onDisconnect: () => void;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MachineConnection({
|
|
||||||
isConnected,
|
|
||||||
machineInfo,
|
|
||||||
machineStatus,
|
|
||||||
machineStatusName,
|
|
||||||
machineError,
|
|
||||||
onConnect,
|
|
||||||
onDisconnect,
|
|
||||||
}: MachineConnectionProps) {
|
|
||||||
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
|
||||||
|
|
||||||
const handleDisconnectClick = () => {
|
|
||||||
if (shouldConfirmDisconnect(machineStatus)) {
|
|
||||||
setShowDisconnectConfirm(true);
|
|
||||||
} else {
|
|
||||||
onDisconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmDisconnect = () => {
|
|
||||||
setShowDisconnectConfirm(false);
|
|
||||||
onDisconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stateVisual = getStateVisualInfo(machineStatus);
|
|
||||||
|
|
||||||
// Map icon names to Heroicons
|
|
||||||
const stateIcons = {
|
|
||||||
ready: CheckCircleIcon,
|
|
||||||
active: BoltIcon,
|
|
||||||
waiting: PauseCircleIcon,
|
|
||||||
complete: CheckCircleIcon,
|
|
||||||
interrupted: PauseCircleIcon,
|
|
||||||
error: ExclamationTriangleIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusBadgeColors = {
|
|
||||||
idle: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
|
|
||||||
info: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
|
|
||||||
active:
|
|
||||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
|
||||||
waiting:
|
|
||||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
|
||||||
warning:
|
|
||||||
"bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
|
|
||||||
complete:
|
|
||||||
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
|
|
||||||
success:
|
|
||||||
"bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
|
|
||||||
interrupted:
|
|
||||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
|
||||||
error:
|
|
||||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
|
||||||
danger:
|
|
||||||
"bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only show error info when connected AND there's an actual error
|
|
||||||
const errorInfo =
|
|
||||||
isConnected && hasError(machineError)
|
|
||||||
? getErrorDetails(machineError)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isConnected ? (
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
|
|
||||||
<div className="flex items-start gap-3 mb-3">
|
|
||||||
<WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
|
||||||
Machine
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
Ready to connect
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onConnect}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Connect to Machine
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-success-600 dark:border-success-500">
|
|
||||||
<div className="flex items-start gap-3 mb-3">
|
|
||||||
<WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
|
||||||
Machine Info
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{machineInfo?.modelNumber || "Brother Embroidery Machine"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error/Info Display */}
|
|
||||||
{errorInfo &&
|
|
||||||
(errorInfo.isInformational ? (
|
|
||||||
<div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-semibold text-info-900 dark:text-info-200 text-xs">
|
|
||||||
{errorInfo.title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-danger-600 dark:text-danger-400 flex-shrink-0">
|
|
||||||
⚠️
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">
|
|
||||||
{errorInfo.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-danger-700 dark:text-danger-300 font-mono">
|
|
||||||
Error Code: 0x
|
|
||||||
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Status Badge */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">
|
|
||||||
Status:
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const Icon = stateIcons[stateVisual.iconName];
|
|
||||||
return <Icon className="w-3.5 h-3.5" />;
|
|
||||||
})()}
|
|
||||||
<span>{machineStatusName}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Machine Info */}
|
|
||||||
{machineInfo && (
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
|
||||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">
|
|
||||||
Max Area
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{(machineInfo.maxWidth / 10).toFixed(1)} ×{" "}
|
|
||||||
{(machineInfo.maxHeight / 10).toFixed(1)} mm
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{machineInfo.totalCount !== undefined && (
|
|
||||||
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">
|
|
||||||
Total Stitches
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{machineInfo.totalCount.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnectClick}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600 text-xs font-medium transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={showDisconnectConfirm}
|
|
||||||
title="Confirm Disconnect"
|
|
||||||
message={`The machine is currently ${machineStatusName.toLowerCase()}. Disconnecting may interrupt the operation. Are you sure you want to disconnect?`}
|
|
||||||
confirmText="Disconnect Anyway"
|
|
||||||
cancelText="Cancel"
|
|
||||||
onConfirm={handleConfirmDisconnect}
|
|
||||||
onCancel={() => setShowDisconnectConfirm(false)}
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -22,6 +22,14 @@ import {
|
||||||
PatternBounds,
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
src/components/PatternCanvasPlaceholder.tsx
Normal file
94
src/components/PatternCanvasPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { PhotoIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export function PatternCanvasPlaceholder() {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 border-gray-400 dark:border-gray-600`}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-4 pb-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<PhotoIcon
|
||||||
|
className={`w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-sm">Pattern Preview</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
No pattern loaded
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 flex-1 flex flex-col">
|
||||||
|
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
|
||||||
|
{/* Decorative background pattern */}
|
||||||
|
<div className="absolute inset-0 opacity-5 dark:opacity-10">
|
||||||
|
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||||
|
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center relative z-10">
|
||||||
|
<div className="relative inline-block mb-6">
|
||||||
|
<svg
|
||||||
|
className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-primary-600 dark:text-primary-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
|
||||||
|
No Pattern Loaded
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
|
||||||
|
Connect to your machine and choose a PES embroidery file to see
|
||||||
|
your design preview
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-primary-400 dark:bg-primary-500 rounded-full"></div>
|
||||||
|
<span>Drag to Position</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-success-400 dark:bg-success-500 rounded-full"></div>
|
||||||
|
<span>Zoom & Pan</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-accent-400 dark:bg-accent-500 rounded-full"></div>
|
||||||
|
<span>Real-time Preview</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
export function PatternPreviewPlaceholder() {
|
|
||||||
return (
|
|
||||||
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
|
|
||||||
<h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">
|
|
||||||
Pattern Preview
|
|
||||||
</h2>
|
|
||||||
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
|
|
||||||
{/* Decorative background pattern */}
|
|
||||||
<div className="absolute inset-0 opacity-5 dark:opacity-10">
|
|
||||||
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
|
||||||
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-4 border-gray-400 dark:border-gray-500 rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center relative z-10">
|
|
||||||
<div className="relative inline-block mb-6">
|
|
||||||
<svg
|
|
||||||
className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-primary-600 dark:text-primary-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
|
|
||||||
No Pattern Loaded
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
|
|
||||||
Connect to your machine and choose a PES embroidery file to see your
|
|
||||||
design preview
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="w-2 h-2 bg-primary-400 dark:bg-primary-500 rounded-full"></div>
|
|
||||||
<span>Drag to Position</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="w-2 h-2 bg-success-400 dark:bg-success-500 rounded-full"></div>
|
|
||||||
<span>Zoom & Pan</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="w-2 h-2 bg-accent-400 dark:bg-accent-500 rounded-full"></div>
|
|
||||||
<span>Real-time Preview</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,6 +4,15 @@ import { usePatternStore } from "../stores/usePatternStore";
|
||||||
import { canDeletePattern } from "../utils/machineStateHelpers";
|
import { 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
47
src/components/ui/badge.tsx
Normal file
47
src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export { Badge, badgeVariants };
|
||||||
63
src/components/ui/button.tsx
Normal file
63
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export { Button, buttonVariants };
|
||||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
59
src/components/ui/tooltip.tsx
Normal file
59
src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,12 @@
|
||||||
"types": ["vite/client", "web-bluetooth", "node"],
|
"types": ["vite/client", "web-bluetooth", "node"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Path Aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
|
|
||||||
|
|
@ -2,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(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue