Compare commits

...

18 commits

Author SHA1 Message Date
Jan-Henrik Bruhn
47c36f92dd
Merge pull request #22 from jhbruhn/feature/shadcn
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
Feature: shadcn ui
2025-12-21 00:25:29 +01:00
8ad8d7c773 fix: tw animate css 2025-12-21 00:24:04 +01:00
8b6eb593d9 feature: Add shadcn Tooltips to AppHeader for better UX
- Migrated serial number tooltip from native title to shadcn Tooltip
  - Shows formatted machine details (serial, MAC, total stitches, service count)
  - Better multi-line formatting with proper spacing

- Added tooltip to machine status badge
  - Shows state description explaining current status

- Added tooltip to auto-refresh spinner
  - Shows "Auto-refreshing machine status"

- Removed redundant title attributes
  - Disconnect button already has clear label
  - Error button has Popover for details

Benefits:
- Consistent tooltip styling across the app
- Better accessibility with ARIA attributes
- Improved readability with proper formatting
- Better positioning and animations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 00:16:52 +01:00
e1a64e9459 fix: Filter out error code 0xDD from error display
- Exclude machine error code 0xDD (221 decimal) from error popover
- This error code is non-critical and should not be displayed to users
- Maintains all other error codes and messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 00:09:53 +01:00
0e84f7ebe5 fix: Add eslint ignore for shadcn component utility exports
- Added eslint-disable-next-line for react-refresh/only-export-components
- Badge and Button components export utility functions (badgeVariants, buttonVariants)
- These utilities are required by shadcn pattern for variant composition
- Suppressing warning is appropriate since this is intentional design

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 00:07:56 +01:00
5849a1e854 feature: Migrate ErrorPopover to shadcn Popover component
- Migrated ErrorPopover to use shadcn PopoverContent
  - Removed forwardRef wrapper (handled by shadcn internally)
  - Replaced custom absolute positioning with PopoverContent component
  - Used cn() utility for cleaner className management
  - Maintained all styling with info/danger color variants

- Updated AppHeader to use Popover pattern
  - Replaced manual state management (showErrorPopover/setErrorPopover)
  - Removed refs and click-outside detection useEffect
  - Wrapped error button in Popover component with PopoverTrigger
  - Simplified code by removing 30+ lines of manual popover handling

Benefits:
- Better keyboard navigation and accessibility (built into Radix UI)
- Automatic focus management and ARIA attributes
- Cleaner, more maintainable code
- Consistent with other shadcn components in the app

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 00:06:38 +01:00
621de91bc8 fix: Improve theme color definitions and dark mode styling
- Fixed CSS variable format by wrapping HSL values in hsl() function
- Removed double hsl() wrapping in @theme section (now uses var() directly)
- Switched dark mode card background to OKLCH for better perceptual uniformity
- Improved dark mode border visibility (17.5% → 37.5% lightness)
- Improved dark mode input visibility (17.5% → 47.5% lightness)
- Changed app background from gray-300 to gray-100 for cleaner appearance
- Enhanced PatternCanvasPlaceholder to match PatternCanvas styling with icon and description

These changes ensure shadcn components use colors correctly and improve overall dark mode readability.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 00:02:22 +01:00
1820bcde77 feature: Migrate ConfirmDialog and BluetoothDevicePicker to shadcn dialogs
- Migrated ConfirmDialog to use shadcn AlertDialog component
  - Replaced custom modal structure with AlertDialogContent, AlertDialogHeader, AlertDialogFooter
  - Used AlertDialogAction and AlertDialogCancel for buttons
  - Maintained danger/warning variant support with border-top styling
  - Simplified code by removing manual escape key handling (built into AlertDialog)

- Migrated BluetoothDevicePicker to use shadcn Dialog component
  - Replaced custom modal structure with DialogContent, DialogHeader, DialogFooter
  - Used shadcn Button components with outline variant for device selection
  - Maintained scanning state UI with loading spinner in DialogDescription
  - Simplified code by removing manual escape key handling (built into Dialog)
  - Disabled close button for better UX during device selection

Both migrations maintain the same external API and functionality while significantly reducing code complexity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 23:13:09 +01:00
3ca5edf4dc fix: Run prettier formatting on all components
- Applied prettier auto-formatting to all component and utility files
- Fixed semicolons, commas, and indentation throughout codebase
- No functional changes, only code style improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 23:03:38 +01:00
7cf4a5de17 feature: Migrate PatternCanvas to shadcn/ui and rename placeholder
- Migrated PatternCanvas component to use shadcn Card components (Card, CardHeader, CardTitle, CardDescription, CardContent)
- Replaced custom zoom control buttons with shadcn Button component using outline variant and icon size
- Renamed PatternPreviewPlaceholder to PatternCanvasPlaceholder for consistency
- Updated all imports and references in App.tsx
- Maintained all existing functionality including Konva canvas rendering, zoom controls, and pattern positioning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 20:12:40 +01:00
bb066d7775 feature: Migrate PatternPreviewPlaceholder to shadcn Card
Migrated PatternPreviewPlaceholder component to use shadcn Card, CardHeader, and CardTitle components for consistent styling with other card-based components.

Changes:
- Replaced outer div with shadcn Card component
- Migrated header to use CardHeader and CardTitle
- Wrapped content in CardContent
- Applied consistent Card padding (p-0 gap-0)
- Maintained header border separator styling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 20:05:13 +01:00
93cca6d2d0 feature: Migrate ProgressMonitor to shadcn/ui and improve styling
Migrated ProgressMonitor component to use shadcn Card, Button, and Progress components. Removed redundant state visual indicator section that duplicated information already shown by progress bars, color blocks, and action buttons. Changed active thread block styling from vibrant accent colors to muted grays for better visual hierarchy.

Changes:
- Replaced outer div with shadcn Card component
- Migrated header to use CardHeader with CardTitle and CardDescription
- Replaced custom progress bars with shadcn Progress components
- Migrated all action buttons (Resume Sewing, Start Sewing, Start Mask Trace) to shadcn Button
- Removed redundant state visual indicator section (Ready/Active/Complete status)
- Removed unused imports (getStateVisualInfo and related icons)
- Applied consistent Card padding (p-0 gap-0, explicit CardHeader and CardContent padding)
- Changed active thread block from accent colors to muted grays (border, background, icon, progress bar)
- Removed violet border override on active color swatch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 20:01:25 +01:00
054524cb5e feature: Enhance PatternInfo with Tooltip and improve card layouts
Added shadcn Tooltip component for interactive pattern information. Wrapped all PatternInfo stat boxes and color swatches in tooltips with detailed metadata and explanations. Migrated PatternSummaryCard to use CardHeader/CardTitle/CardDescription for better semantic structure. Fixed Card component spacing issues across all cards.

Changes:
- Installed and added shadcn Tooltip component
- Added tooltips to Size, Stitches, and Colors stat boxes with explanatory text
- Wrapped color swatches in Tooltips with detailed thread information
- Added Separator between pattern stats and colors sections
- Migrated PatternSummaryCard to use CardHeader with semantic title/description
- Fixed Card gap-0 on all cards (FileUpload, PatternSummaryCard, ConnectionPrompt)
- Added explicit padding to PatternSummaryCard: CardHeader (p-4 pb-3) and CardContent (px-4 pt-0 pb-4)
- Updated components.json to use src/ paths instead of @/ aliases to fix shadcn install location

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 19:52:16 +01:00
2544504933 feature: Update ConnectionPrompt to use shadcn Card component
Migrated ConnectionPrompt to use shadcn/ui Card and CardContent components for consistency with other card-based components (FileUpload, PatternSummaryCard). Maintains the same visual appearance while using the unified Card system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 19:41:17 +01:00
ed3950b5d0 feature: Migrate FileUpload to shadcn/ui and fix dark mode
Migrated FileUpload component to use shadcn/ui Card, Button, Alert, and Progress components. Updated dark mode CSS variables to use media query approach for automatic system theme detection. Fixed Card component padding and button styling for better visual consistency.

Changes:
- Replaced custom div with shadcn Card and CardContent components
- Migrated buttons to shadcn Button component with outline variant for Choose File
- Replaced custom alerts with shadcn Alert components
- Replaced custom progress bars with shadcn Progress component
- Fixed Card padding by adding p-0 to Card and rounded-lg to CardContent
- Changed dark mode from .dark class to @media (prefers-color-scheme: dark)
- Fixed primary-foreground color in dark mode for proper white text contrast

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 19:38:27 +01:00
bd80e95004 feature: Migrate SkeletonLoader and PatternSummaryCard to shadcn/ui
- Replace SkeletonLoader with shadcn Skeleton component
- Simplify gradient animation to use shadcn's built-in pulse
- Migrate PatternSummaryCard to shadcn Card and Button
- Replace custom delete button with shadcn Button (outline variant)
- Use Loader2 from lucide-react for loading spinner

Code reduction:
- SkeletonLoader: Removed 8 lines of custom gradient classes
- Delete button: ~70% reduction (200+ char className → cleaner Button)
- Card wrapper: Semantic Card structure vs verbose div classes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 15:44:51 +01:00
365b0c7ae3 feature: Migrate AppHeader buttons and badges to shadcn/ui
- Replace Disconnect button with shadcn Button (outline variant)
- Replace status badge with shadcn Badge component
- Replace error button with shadcn Button (destructive variant)
- Use cn() helper for conditional className composition
- Preserve glass morphism effects and custom styling

Code reduction:
- Disconnect button: ~40% reduction
- Status badge: ~30% reduction
- Error button: ~60% reduction

Improved maintainability with semantic component usage and cleaner code structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 15:41:33 +01:00
08532d0b01 feature: Add shadcn/ui component library and migrate ConnectionPrompt
- Initialize shadcn/ui with cssVariables and path aliases
- Install core components: Button, Alert, Badge, Card, Dialog, etc.
- Configure Tailwind v4 theme integration with shadcn color system
- Migrate ConnectionPrompt to use shadcn Button and Alert components
- Add cursor-pointer to button variants for better UX
- Remove unused MachineConnection.tsx component

Code reduction: 87% in ConnectionPrompt button (150+ char className → 20 char)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 15:33:02 +01:00
34 changed files with 6376 additions and 1635 deletions

View file

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

21
components.json Normal file
View file

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

4060
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,14 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import type { BluetoothDevice } from "../types/electron"; import type { BluetoothDevice } from "../types/electron";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function BluetoothDevicePicker() { export function BluetoothDevicePicker() {
const [devices, setDevices] = useState<BluetoothDevice[]>([]); const [devices, setDevices] = useState<BluetoothDevice[]>([]);
@ -40,48 +49,17 @@ 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 <DialogHeader>
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" <DialogTitle>Select Bluetooth Device</DialogTitle>
onClick={(e) => e.stopPropagation()} <DialogDescription>
role="dialog"
aria-labelledby="bluetooth-picker-title"
aria-describedby="bluetooth-picker-message"
>
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
<h3
id="bluetooth-picker-title"
className="m-0 text-base lg:text-lg font-semibold dark:text-white"
>
Select Bluetooth Device
</h3>
</div>
<div className="p-6">
{isScanning && devices.length === 0 ? ( {isScanning && devices.length === 0 ? (
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300"> <div className="flex items-center gap-3 py-2">
<svg <svg
className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400" className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -95,56 +73,47 @@ export function BluetoothDevicePicker() {
r="10" r="10"
stroke="currentColor" stroke="currentColor"
strokeWidth="4" strokeWidth="4"
></circle> />
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" 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" 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> </svg>
<span id="bluetooth-picker-message"> <span>Scanning for Bluetooth devices...</span>
Scanning for Bluetooth devices...
</span>
</div> </div>
) : ( ) : (
<> `${devices.length} device${devices.length !== 1 ? "s" : ""} found. Select a device to connect:`
<p )}
id="bluetooth-picker-message" </DialogDescription>
className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100" </DialogHeader>
>
{devices.length} device{devices.length !== 1 ? "s" : ""} found. {!isScanning && devices.length > 0 && (
Select a device to connect:
</p>
<div className="space-y-2"> <div className="space-y-2">
{devices.map((device) => ( {devices.map((device) => (
<button <Button
key={device.deviceId} key={device.deviceId}
onClick={() => handleSelectDevice(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" variant="outline"
aria-label={`Connect to ${device.deviceName}`} className="w-full h-auto px-4 py-3 justify-start"
> >
<div className="font-semibold text-gray-900 dark:text-white"> <div className="text-left">
{device.deviceName} <div className="font-semibold">{device.deviceName}</div>
</div> <div className="text-xs text-muted-foreground mt-1">
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{device.deviceId} {device.deviceId}
</div> </div>
</button> </div>
</Button>
))} ))}
</div> </div>
</>
)} )}
</div>
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600"> <DialogFooter>
<button <Button variant="outline" onClick={handleCancel}>
onClick={handleCancel}
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Cancel device selection"
>
Cancel Cancel
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
); );
} }

View file

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

View file

@ -2,6 +2,9 @@ import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore"; import { useMachineStore } from "../stores/useMachineStore";
import { isBluetoothSupported } from "../utils/bluetoothSupport"; import { isBluetoothSupported } from "../utils/bluetoothSupport";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
export function ConnectionPrompt() { export function ConnectionPrompt() {
const { connect } = useMachineStore( const { connect } = useMachineStore(
@ -12,7 +15,8 @@ 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">
<CardContent className="p-4 rounded-lg">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5"> <div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
<svg <svg
@ -38,28 +42,27 @@ export function ConnectionPrompt() {
</p> </p>
</div> </div>
</div> </div>
<button <Button onClick={connect} className="w-full">
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 Connect to Machine
</button> </Button>
</div> </CardContent>
</Card>
); );
} }
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>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-semibold text-warning-900 dark:text-warning-100"> <p className="text-sm font-semibold text-warning-900 dark:text-warning-100">
Please try one of these options: Please try one of these options:
@ -79,8 +82,7 @@ export function ConnectionPrompt() {
</li> </li>
</ul> </ul>
</div> </div>
</div> </AlertDescription>
</div> </Alert>
</div>
); );
} }

View file

@ -1,19 +1,24 @@
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,
isPairingError,
errorMessage,
pyodideError,
}: ErrorPopoverContentProps) {
const errorDetails = getErrorDetails(machineError); const errorDetails = getErrorDetails(machineError);
const isPairingErr = isPairingError; const isPairingErr = isPairingError;
const errorMsg = pyodideError || errorMessage || ""; const errorMsg = pyodideError || errorMessage || "";
@ -44,31 +49,29 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
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={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} /> <Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
<div className="flex-1"> <div className="flex-1">
<h3 className={`text-base font-semibold ${textColor} mb-2`}> <h3 className={cn("text-base font-semibold mb-2", textColor)}>
{title} {title}
</h3> </h3>
<p className={`text-sm ${descColor} mb-3`}> <p className={cn("text-sm mb-3", descColor)}>
{errorDetails?.description || errorMsg} {errorDetails?.description || errorMsg}
</p> </p>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && ( {errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<> <>
<h4 className={`text-sm font-semibold ${textColor} mb-2`}> <h4 className={cn("text-sm font-semibold mb-2", textColor)}>
{isInfo ? "Steps:" : "How to Fix:"} {isInfo ? "Steps:" : "How to Fix:"}
</h4> </h4>
<ol <ol
className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`} className={cn(
"list-decimal list-inside text-sm space-y-1.5",
listColor,
)}
> >
{errorDetails.solutions.map((solution, index) => ( {errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2"> <li key={index} className="pl-2">
@ -79,17 +82,13 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
</> </>
)} )}
{machineError !== undefined && !errorDetails?.isInformational && ( {machineError !== undefined && !errorDetails?.isInformational && (
<p className={`text-xs ${descColor} mt-3 font-mono`}> <p className={cn("text-xs mt-3 font-mono", descColor)}>
Error Code: 0x Error Code: 0x
{machineError.toString(16).toUpperCase().padStart(2, "0")} {machineError.toString(16).toUpperCase().padStart(2, "0")}
</p> </p>
)} )}
</div> </div>
</div> </div>
</div> </PopoverContent>
</div>
); );
}, }
);
ErrorPopover.displayName = "ErrorPopover";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -8,6 +8,12 @@
"types": ["vite/client", "web-bluetooth", "node"], "types": ["vite/client", "web-bluetooth", "node"],
"skipLibCheck": true, "skipLibCheck": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,

View file

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