Implement unified compact card design system across all UI components

Replace inconsistent card layouts with a cohesive, space-efficient design
pattern featuring color-coded borders, icon headers, and compact spacing.
This redesign significantly reduces vertical space usage while improving
visual hierarchy and scannability.

UI/UX improvements:
- Apply consistent card design with border-left accent colors
- Add icon + title + subtitle header pattern to all cards
- Reduce padding from p-6 to p-4 for more compact layout
- Use smaller, tighter font sizes (text-xs, text-sm)
- Implement color-coded borders for quick visual identification

Component-specific changes:
- MachineConnection: Green/gray border, WiFi icon, compact status display
- PatternSummaryCard: Blue border, Document icon (new component)
- FileUpload: Orange/gray border, Document icon, inline button layout
- ProgressMonitor: Purple border, Chart icon, single-column layout
- PatternCanvas: Teal/gray border, Photo icon, dimensions in header

Conditional rendering optimizations:
- Show FileUpload OR PatternSummaryCard based on upload state
- Move ProgressMonitor to left column with PatternSummary
- Relocate NextStepGuide below PatternCanvas for better space usage
- Remove duplicate delete button from ProgressMonitor

Space savings: ~40-50% reduction in vertical space usage across all cards

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik 2025-12-07 12:37:08 +01:00
parent 9b536e9deb
commit 99ed1adb68
6 changed files with 469 additions and 376 deletions

View file

@ -6,9 +6,11 @@ import { PatternCanvas } from './components/PatternCanvas';
import { ProgressMonitor } from './components/ProgressMonitor';
import { WorkflowStepper } from './components/WorkflowStepper';
import { NextStepGuide } from './components/NextStepGuide';
import { PatternSummaryCard } from './components/PatternSummaryCard';
import type { PesPatternData } from './utils/pystitchConverter';
import { pyodideLoader } from './utils/pyodideLoader';
import { hasError } from './utils/errorCodeHelpers';
import { canDeletePattern } from './utils/machineStateHelpers';
import './App.css';
function App() {
@ -153,17 +155,6 @@ function App() {
<div className="grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-6">
{/* Left Column - Controls */}
<div className="flex flex-col gap-6">
{/* Next Step Guide - Always visible */}
<NextStepGuide
machineStatus={machine.machineStatus}
isConnected={machine.isConnected}
hasPattern={pesData !== null}
patternUploaded={patternUploaded}
hasError={hasError(machine.machineError)}
errorMessage={machine.error || undefined}
errorCode={machine.machineError}
/>
{/* Machine Connection - Always visible */}
<MachineConnection
isConnected={machine.isConnected}
@ -177,8 +168,8 @@ function App() {
onRefresh={machine.refreshStatus}
/>
{/* Pattern File - Only show when connected */}
{machine.isConnected && (
{/* Pattern File - Show during upload stage (before pattern is uploaded) */}
{machine.isConnected && !patternUploaded && (
<FileUpload
isConnected={machine.isConnected}
machineStatus={machine.machineStatus}
@ -195,6 +186,32 @@ function App() {
isUploading={machine.isUploading}
/>
)}
{/* Compact Pattern Summary - Show after upload (during sewing stages) */}
{machine.isConnected && patternUploaded && pesData && (
<PatternSummaryCard
pesData={pesData}
fileName={currentFileName}
onDeletePattern={handleDeletePattern}
canDelete={canDeletePattern(machine.machineStatus)}
isDeleting={machine.isDeleting}
/>
)}
{/* Progress Monitor - Show when pattern is uploaded */}
{machine.isConnected && patternUploaded && (
<ProgressMonitor
machineStatus={machine.machineStatus}
patternInfo={machine.patternInfo}
sewingProgress={machine.sewingProgress}
pesData={pesData}
onStartMaskTrace={machine.startMaskTrace}
onStartSewing={machine.startSewing}
onResumeSewing={machine.resumeSewing}
onDeletePattern={handleDeletePattern}
isDeleting={machine.isDeleting}
/>
)}
</div>
{/* Right Column - Pattern Preview */}
@ -254,20 +271,16 @@ function App() {
</div>
)}
{/* Progress Monitor - Wide section below pattern preview */}
{machine.isConnected && patternUploaded && (
<ProgressMonitor
{/* Next Step Guide - Below pattern preview */}
<NextStepGuide
machineStatus={machine.machineStatus}
patternInfo={machine.patternInfo}
sewingProgress={machine.sewingProgress}
pesData={pesData}
onStartMaskTrace={machine.startMaskTrace}
onStartSewing={machine.startSewing}
onResumeSewing={machine.resumeSewing}
onDeletePattern={handleDeletePattern}
isDeleting={machine.isDeleting}
isConnected={machine.isConnected}
hasPattern={pesData !== null}
patternUploaded={patternUploaded}
hasError={hasError(machine.machineError)}
errorMessage={machine.error || undefined}
errorCode={machine.machineError}
/>
)}
</div>
</div>
</div>

View file

@ -3,7 +3,7 @@ import { convertPesToPen, type PesPatternData } from '../utils/pystitchConverter
import { MachineStatus } from '../types/machine';
import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
import { PatternInfoSkeleton } from './SkeletonLoader';
import { ArrowUpTrayIcon, CheckCircleIcon } from '@heroicons/react/24/solid';
import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon } from '@heroicons/react/24/solid';
interface FileUploadProps {
isConnected: boolean;
@ -80,28 +80,98 @@ export function FileUpload({
}
}, [pesData, displayFileName, onUpload, patternOffset]);
const borderColor = pesData ? 'border-orange-600 dark:border-orange-500' : 'border-gray-400 dark:border-gray-600';
const iconColor = pesData ? 'text-orange-600 dark:text-orange-400' : 'text-gray-600 dark:text-gray-400';
return (
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200">
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white">Pattern File</h2>
<div className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}>
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern File</h3>
{pesData && displayFileName ? (
<p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={displayFileName}>
{displayFileName}
</p>
) : (
<p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p>
)}
</div>
</div>
<div>
{resumeAvailable && resumeFileName && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 rounded mb-4">
<p className="text-sm text-green-800 dark:text-green-200">
<strong>Loaded cached pattern:</strong> "{resumeFileName}"
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-3 py-2 rounded mb-3">
<p className="text-xs text-green-800 dark:text-green-200">
<strong>Cached:</strong> "{resumeFileName}"
</p>
</div>
)}
{patternUploaded && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 px-4 py-3 rounded mb-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Pattern uploaded successfully!</strong> The pattern is now locked and cannot be changed.
To upload a different pattern, you must first complete or delete the current one.
</p>
{isLoading && <PatternInfoSkeleton />}
{!isLoading && pesData && (
<div className="mb-3">
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.stitchCount.toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
<div className="flex gap-1">
{pesData.threads.slice(0, 8).map((thread, idx) => (
<div
key={idx}
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: thread.hex }}
title={`Thread ${idx + 1}: ${thread.hex}`}
/>
))}
{pesData.colorCount > 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-[7px] font-bold text-gray-600 dark:text-gray-300">
+{pesData.colorCount - 8}
</div>
)}
</div>
</div>
</div>
)}
{pesData && !canUploadPattern(machineStatus) && (
<div className="bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 px-3 py-2 rounded border border-yellow-200 dark:border-yellow-800 mb-3 text-xs">
Cannot upload while {getMachineStateCategory(machineStatus)}
</div>
)}
{isUploading && uploadProgress < 100 && (
<div className="mb-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Uploading</span>
<span className="text-xs font-bold text-orange-600 dark:text-orange-400">
{uploadProgress > 0 ? uploadProgress.toFixed(1) + '%' : 'Starting...'}
</span>
</div>
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
<div
className="h-full bg-gradient-to-r from-orange-500 via-orange-600 to-orange-700 dark:from-orange-600 dark:via-orange-700 dark:to-orange-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
<div className="flex gap-2">
<input
type="file"
accept=".pes"
@ -112,23 +182,23 @@ export function FileUpload({
/>
<label
htmlFor="file-input"
className={`inline-flex items-center gap-2 px-6 py-3 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm transition-all ${
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded font-semibold text-xs transition-all ${
!pyodideReady || isLoading || patternUploaded
? 'opacity-50 cursor-not-allowed grayscale-[0.3]'
: 'cursor-pointer hover:bg-gray-700 dark:hover:bg-gray-600 hover:shadow-lg active:scale-[0.98]'
? 'opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white'
: 'cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
}`}
>
{isLoading ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Loading Pattern...</span>
<span>Loading...</span>
</>
) : !pyodideReady ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@ -136,107 +206,37 @@ export function FileUpload({
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-5 h-5" />
<span>Pattern Locked</span>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<span>Choose PES File</span>
)}
</label>
{isLoading && <PatternInfoSkeleton />}
{!isLoading && pesData && (
<div className="mt-4 animate-fadeIn">
<h3 className="text-base font-semibold my-4 dark:text-white">Pattern Information</h3>
<div className="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700/50 dark:to-gray-800/50 p-4 rounded-lg space-y-3 border border-gray-200 dark:border-gray-600 shadow-sm">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700 dark:text-gray-300">File Name:</span>
<span className="font-semibold text-gray-900 dark:text-gray-100 text-right max-w-[200px] truncate" title={displayFileName}>
{displayFileName}
</span>
</div>
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700 dark:text-gray-300">Pattern Size:</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span>
</div>
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700 dark:text-gray-300">Thread Colors:</span>
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900 dark:text-gray-100">{pesData.colorCount}</span>
<div className="flex gap-1">
{pesData.threads.slice(0, 5).map((thread, idx) => (
<div
key={idx}
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600 shadow-sm"
style={{ backgroundColor: thread.hex }}
title={`Thread ${idx + 1}: ${thread.hex}`}
/>
))}
{pesData.colorCount > 5 && (
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[8px] font-bold text-gray-600 dark:text-gray-300">
+{pesData.colorCount - 5}
</div>
)}
</div>
</div>
</div>
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700 dark:text-gray-300">Total Stitches:</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{pesData.stitchCount.toLocaleString()}</span>
</div>
</div>
</div>
)}
{pesData && canUploadPattern(machineStatus) && !patternUploaded && uploadProgress < 100 && (
<button
onClick={handleUpload}
disabled={!isConnected || isUploading}
className="mt-4 inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 dark:bg-blue-700 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 disabled:hover:shadow-none disabled:active:scale-100"
className="px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={isUploading ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete` : 'Upload pattern to machine'}
>
{isUploading ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 animate-spin inline mr-1" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Uploading... {uploadProgress > 0 ? uploadProgress.toFixed(0) + '%' : ''}</span>
{uploadProgress > 0 ? uploadProgress.toFixed(0) + '%' : 'Uploading'}
</>
) : (
<>
<ArrowUpTrayIcon className="w-5 h-5" />
<span>Upload to Machine</span>
<ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
Upload
</>
)}
</button>
)}
{pesData && !canUploadPattern(machineStatus) && (
<div className="bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 px-4 py-3 rounded-lg border border-yellow-200 dark:border-yellow-800 my-4 font-medium animate-fadeIn">
Cannot upload pattern while machine is {getMachineStateCategory(machineStatus)}
</div>
)}
{isUploading && uploadProgress < 100 && (
<div className="mt-4 animate-fadeIn">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Uploading to Machine</span>
<span className="text-sm font-bold text-blue-600 dark:text-blue-400">{uploadProgress > 0 ? uploadProgress.toFixed(1) + '%' : 'Starting...'}</span>
</div>
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
<div
className="h-full bg-gradient-to-r from-blue-500 via-blue-600 to-blue-700 dark:from-blue-600 dark:via-blue-700 dark:to-blue-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center">Please wait while your pattern is being transferred...</p>
</div>
)}
</div>
</div>
);

View file

@ -5,6 +5,7 @@ import {
BoltIcon,
PauseCircleIcon,
ExclamationTriangleIcon,
WifiIcon,
} from '@heroicons/react/24/solid';
import type { MachineInfo } from '../types/machine';
import { MachineStatus } from '../types/machine';
@ -62,73 +63,77 @@ export function MachineConnection({
};
const statusBadgeColors = {
idle: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-800 dark:text-cyan-300 border-cyan-200 dark:border-cyan-700',
info: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-800 dark:text-cyan-300 border-cyan-200 dark:border-cyan-700',
active: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 border-yellow-200 dark:border-yellow-700',
waiting: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 border-yellow-200 dark:border-yellow-700',
warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 border-yellow-200 dark:border-yellow-700',
complete: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-200 dark:border-green-700',
success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-200 dark:border-green-700',
interrupted: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-200 dark:border-red-700',
error: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-200 dark:border-red-700',
danger: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-200 dark:border-red-700',
idle: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-800 dark:text-cyan-300',
info: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-800 dark:text-cyan-300',
active: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
waiting: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
complete: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
interrupted: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
error: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
danger: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
};
// Only show error info when connected AND there's an actual error
const errorInfo = (isConnected && hasError(machineError)) ? getErrorDetails(machineError) : null;
return (
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200">
<div className="flex items-center justify-between mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600">
<h2 className="text-xl font-semibold dark:text-white">Machine Connection</h2>
<div className="flex items-center gap-3">
{isConnected && isPolling && (
<span className="w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full animate-pulse" title="Auto-refreshing" aria-label="Auto-refreshing machine status"></span>
)}
{isConnected && (
<button
onClick={handleDisconnectClick}
className="px-3 py-1.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-md 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="Disconnect from embroidery machine"
>
Disconnect
</button>
)}
<>
{!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 Connection</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">Not connected</p>
</div>
</div>
{!isConnected ? (
<div className="flex gap-3 mt-4 flex-wrap">
<button
onClick={onConnect}
className="px-6 py-2.5 bg-blue-600 dark:bg-blue-700 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Connect to embroidery machine"
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
>
Connect to Machine
</button>
</div>
) : (
<div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-green-600 dark:border-green-500">
<div className="flex items-start gap-3 mb-3">
<WifiIcon className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Machine Connected</h3>
{isPolling && (
<span className="w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full animate-pulse" title="Auto-refreshing" aria-label="Auto-refreshing machine status"></span>
)}
</div>
{machineInfo && (
<p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={machineInfo.serialNumber}>
{machineInfo.serialNumber}
</p>
)}
</div>
</div>
{/* Error/Info Display */}
{errorInfo && (
errorInfo.isInformational ? (
// Informational messages (like initialization steps)
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-2">
<InformationCircleIcon className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<InformationCircleIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold text-blue-900 dark:text-blue-200 text-sm">{errorInfo.title}</div>
<div className="font-semibold text-blue-900 dark:text-blue-200 text-xs">{errorInfo.title}</div>
</div>
</div>
</div>
) : (
// Regular errors shown as errors
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="mb-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start gap-2">
<span className="text-red-600 dark:text-red-400 text-lg flex-shrink-0"></span>
<span className="text-red-600 dark:text-red-400 flex-shrink-0"></span>
<div className="flex-1 min-w-0">
<div className="font-semibold text-red-900 dark:text-red-200 text-sm mb-1">{errorInfo.title}</div>
<div className="text-xs text-red-700 dark:text-red-300 font-mono">
<div className="font-semibold text-red-900 dark:text-red-200 text-xs mb-1">{errorInfo.title}</div>
<div className="text-[10px] text-red-700 dark:text-red-300 font-mono">
Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
</div>
</div>
@ -137,36 +142,30 @@ export function MachineConnection({
)
)}
{/* Machine Status */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Status:</span>
<span className={`flex items-center gap-2 px-3 py-1.5 rounded-lg font-semibold text-sm ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}>
{/* 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-4 h-4" />;
return <Icon className="w-3.5 h-3.5" />;
})()}
<span>{machineStatusName}</span>
</span>
</div>
</div>
{/* Machine Info */}
{machineInfo && (
<div className="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="font-medium text-gray-600 dark:text-gray-400">Serial Number:</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{machineInfo.serialNumber}</span>
</div>
<div className="flex justify-between text-sm">
<span className="font-medium text-gray-600 dark:text-gray-400">Max Area:</span>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-50 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="flex justify-between text-sm">
<span className="font-medium text-gray-600 dark:text-gray-400">Total Stitches:</span>
<div className="bg-gray-50 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>
@ -174,6 +173,13 @@ export function MachineConnection({
)}
</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>
)}
@ -187,6 +193,6 @@ export function MachineConnection({
onCancel={() => setShowDisconnectConfirm(false)}
variant="danger"
/>
</div>
</>
);
}

View file

@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Stage, Layer, Group } from 'react-konva';
import Konva from 'konva';
import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon } from '@heroicons/react/24/solid';
import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon } from '@heroicons/react/24/solid';
import type { PesPatternData } from '../utils/pystitchConverter';
import type { SewingProgress, MachineInfo } from '../types/machine';
import { calculateInitialScale } from '../utils/konvaRenderers';
@ -181,9 +181,24 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
}
}, [onPatternOffsetChange]);
const borderColor = pesData ? 'border-teal-600 dark:border-teal-500' : 'border-gray-400 dark:border-gray-600';
const iconColor = pesData ? 'text-teal-600 dark:text-teal-400' : 'text-gray-600 dark:text-gray-400';
return (
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200">
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white">Pattern Preview</h2>
<div className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}>
<div className="flex items-start gap-3 mb-3">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern Preview</h3>
{pesData ? (
<p className="text-xs text-gray-600 dark:text-gray-400">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} × {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</p>
) : (
<p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p>
)}
</div>
</div>
<div className="relative w-full h-[600px] border border-gray-300 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-900 overflow-hidden" ref={containerRef}>
{containerSize.width > 0 && (
<Stage
@ -281,25 +296,19 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
{pesData && (
<>
{/* Thread Legend Overlay */}
<div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-3 rounded-lg shadow-lg z-10 max-w-[150px]">
<h4 className="m-0 mb-2 text-[13px] font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Threads</h4>
<div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-lg shadow-lg z-10 max-w-[150px]">
<h4 className="m-0 mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Threads</h4>
{pesData.threads.map((thread, index) => (
<div key={index} className="flex items-center gap-2 mb-1.5 last:mb-0">
<div
className="w-5 h-5 rounded border border-black dark:border-gray-300 flex-shrink-0"
className="w-4 h-4 rounded border border-black dark:border-gray-300 flex-shrink-0"
style={{ backgroundColor: thread.hex }}
/>
<span className="text-xs text-gray-900 dark:text-gray-100">Thread {index + 1}</span>
<span className="text-[11px] text-gray-900 dark:text-gray-100">Thread {index + 1}</span>
</div>
))}
</div>
{/* Pattern Dimensions Overlay */}
<div className="absolute bottom-[165px] right-5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg z-[11] text-sm font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</div>
{/* Pattern Offset Indicator */}
<div className={`absolute bottom-20 right-5 backdrop-blur-sm p-2.5 px-3.5 rounded-lg shadow-lg z-[11] min-w-[180px] transition-colors ${
patternUploaded ? 'bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600' : 'bg-white/95 dark:bg-gray-800/95'

View file

@ -0,0 +1,90 @@
import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { PesPatternData } from '../utils/pystitchConverter';
interface PatternSummaryCardProps {
pesData: PesPatternData;
fileName: string;
onDeletePattern: () => void;
canDelete: boolean;
isDeleting: boolean;
}
export function PatternSummaryCard({
pesData,
fileName,
onDeletePattern,
canDelete,
isDeleting
}: PatternSummaryCardProps) {
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-blue-600 dark:border-blue-500">
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Active Pattern</h3>
<p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={fileName}>
{fileName}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.stitchCount.toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
<div className="flex gap-1">
{pesData.threads.slice(0, 8).map((thread, idx) => (
<div
key={idx}
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: thread.hex }}
title={`Thread ${idx + 1}: ${thread.hex}`}
/>
))}
{pesData.colorCount > 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-[7px] font-bold text-gray-600 dark:text-gray-300">
+{pesData.colorCount - 8}
</div>
)}
</div>
</div>
{canDelete && (
<button
onClick={onDeletePattern}
disabled={isDeleting}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded border border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/30 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{isDeleting ? (
<>
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Deleting...
</>
) : (
<>
<TrashIcon className="w-3 h-3" />
Delete Pattern
</>
)}
</button>
)}
</div>
);
}

View file

@ -6,7 +6,8 @@ import {
CheckBadgeIcon,
ClockIcon,
PauseCircleIcon,
ExclamationCircleIcon
ExclamationCircleIcon,
ChartBarIcon
} from '@heroicons/react/24/solid';
import type { PatternInfo, SewingProgress } from '../types/machine';
import { MachineStatus } from '../types/machine';
@ -14,7 +15,6 @@ import type { PesPatternData } from '../utils/pystitchConverter';
import {
canStartSewing,
canStartMaskTrace,
canDeletePattern,
canResumeSewing,
getStateVisualInfo
} from '../utils/machineStateHelpers';
@ -39,8 +39,6 @@ export function ProgressMonitor({
onStartMaskTrace,
onStartSewing,
onResumeSewing,
onDeletePattern,
isDeleting = false,
}: ProgressMonitorProps) {
// State indicators
const isMaskTraceComplete = machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
@ -93,65 +91,68 @@ export function ProgressMonitor({
);
const stateIndicatorColors = {
idle: 'bg-blue-50 dark:bg-blue-900/20 border-l-blue-600',
info: 'bg-blue-50 dark:bg-blue-900/20 border-l-blue-600',
active: 'bg-yellow-50 dark:bg-yellow-900/20 border-l-yellow-500',
waiting: 'bg-yellow-50 dark:bg-yellow-900/20 border-l-yellow-500',
warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-l-yellow-500',
complete: 'bg-green-50 dark:bg-green-900/20 border-l-green-600',
success: 'bg-green-50 dark:bg-green-900/20 border-l-green-600',
interrupted: 'bg-red-50 dark:bg-red-900/20 border-l-red-600',
error: 'bg-red-50 dark:bg-red-900/20 border-l-red-600',
danger: 'bg-red-50 dark:bg-red-900/20 border-l-red-600',
idle: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600',
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600',
active: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
waiting: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
complete: 'bg-green-50 dark:bg-green-900/20 border-green-600',
success: 'bg-green-50 dark:bg-green-900/20 border-green-600',
interrupted: 'bg-red-50 dark:bg-red-900/20 border-red-600',
error: 'bg-red-50 dark:bg-red-900/20 border-red-600',
danger: 'bg-red-50 dark:bg-red-900/20 border-red-600',
};
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 animate-fadeIn">
<h2 className="text-lg font-semibold mb-3 pb-2 border-b border-gray-300 dark:border-gray-600 dark:text-white">Sewing Progress</h2>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-purple-600 dark:border-purple-500">
<div className="flex items-start gap-3 mb-3">
<ChartBarIcon className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Sewing Progress</h3>
{sewingProgress && (
<p className="text-xs text-gray-600 dark:text-gray-400">
{progressPercent.toFixed(1)}% complete
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Left Column - Pattern Info & Progress */}
<div>
{/* Pattern Info */}
{patternInfo && (
<div className="bg-gray-50 dark:bg-gray-700/50 p-3 rounded-lg mb-3">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<span className="text-gray-600 dark:text-gray-400 block text-xs">Total Stitches</span>
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-50 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">{patternInfo.totalStitches.toLocaleString()}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400 block text-xs">Est. Time</span>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Est. Time</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{Math.floor(patternInfo.totalTime / 60)}:{String(patternInfo.totalTime % 60).padStart(2, '0')}
</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400 block text-xs">Speed</span>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Speed</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{patternInfo.speed} spm</span>
</div>
</div>
</div>
)}
{/* Progress Bar */}
{sewingProgress && (
<div className="mb-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Progress</span>
<span className="text-xl font-bold text-blue-600 dark:text-blue-400">{progressPercent.toFixed(1)}%</span>
</div>
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
<div className="h-full bg-gradient-to-r from-blue-600 to-blue-700 dark:from-blue-600 dark:to-blue-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]" style={{ width: `${progressPercent}%` }} />
<div className="h-full bg-gradient-to-r from-purple-600 to-purple-700 dark:from-purple-600 dark:to-purple-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]" style={{ width: `${progressPercent}%` }} />
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded-lg grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-gray-600 dark:text-gray-400 block text-xs">Current Stitch</span>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Current Stitch</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{sewingProgress.currentStitch.toLocaleString()} / {patternInfo?.totalStitches.toLocaleString() || 0}
</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400 block text-xs">Time Elapsed</span>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Time Elapsed</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{Math.floor(sewingProgress.currentTime / 60)}:{String(sewingProgress.currentTime % 60).padStart(2, '0')}
</span>
@ -163,92 +164,31 @@ export function ProgressMonitor({
{/* State Visual Indicator */}
{patternInfo && (() => {
const iconMap = {
ready: <ClockIcon className="w-6 h-6 text-blue-600 dark:text-blue-400" />,
active: <PlayIcon className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />,
waiting: <PauseCircleIcon className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />,
complete: <CheckBadgeIcon className="w-6 h-6 text-green-600 dark:text-green-400" />,
interrupted: <PauseCircleIcon className="w-6 h-6 text-red-600 dark:text-red-400" />,
error: <ExclamationCircleIcon className="w-6 h-6 text-red-600 dark:text-red-400" />
ready: <ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />,
active: <PlayIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />,
waiting: <PauseCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />,
complete: <CheckBadgeIcon className="w-5 h-5 text-green-600 dark:text-green-400" />,
interrupted: <PauseCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />,
error: <ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
};
return (
<div className={`flex items-center gap-3 p-3 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}>
<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-sm dark:text-gray-100">{stateVisual.label}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">{stateVisual.description}</div>
<div className="font-semibold text-xs dark:text-gray-100">{stateVisual.label}</div>
<div className="text-[10px] text-gray-600 dark:text-gray-400">{stateVisual.description}</div>
</div>
</div>
);
})()}
{/* Action buttons */}
<div className="flex gap-2 flex-wrap">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<button
onClick={onResumeSewing}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 dark:bg-blue-700 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-4 h-4" />
Resume Sewing
</button>
)}
{/* Start Sewing - primary action */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<button
onClick={onStartSewing}
className="px-4 py-2.5 bg-blue-600 dark:bg-blue-700 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="Start sewing the pattern"
>
Start Sewing
</button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<button
onClick={onStartMaskTrace}
className="px-4 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={isMaskTraceComplete ? 'Start mask trace again' : 'Start mask trace'}
>
{isMaskTraceComplete ? 'Trace Again' : 'Start Mask Trace'}
</button>
)}
{/* Delete - destructive action, always last */}
{patternInfo && canDeletePattern(machineStatus) && (
<button
onClick={onDeletePattern}
disabled={isDeleting}
className="px-4 py-2.5 bg-red-600 dark:bg-red-700 text-white rounded-lg font-semibold text-sm hover:bg-red-700 dark:hover:bg-red-600 active:bg-red-800 dark:active:bg-red-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-red-300 dark:focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ml-auto disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-600 disabled:hover:shadow-none disabled:active:scale-100"
aria-label={isDeleting ? "Deleting pattern..." : "Delete the current pattern from machine"}
>
{isDeleting ? (
<>
<svg className="w-4 h-4 animate-spin inline mr-2" 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...
</>
) : (
'Delete Pattern'
)}
</button>
)}
</div>
</div>
{/* Right Column - Color Blocks */}
<div>
{/* Color Blocks */}
{colorBlocks.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Color Blocks</h3>
<div className="mb-3">
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300">Color Blocks</h4>
<div className="flex flex-col gap-2">
{colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch;
@ -265,23 +205,23 @@ export function ProgressMonitor({
return (
<div
key={index}
className={`p-3 rounded-lg border-2 transition-all duration-300 ${
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted
? 'border-green-600 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30'
? 'border-green-600 bg-green-50 dark:bg-green-900/20'
: isCurrent
? 'border-blue-600 bg-blue-50 dark:bg-blue-900/20 shadow-lg shadow-blue-600/20 animate-pulseGlow'
: 'border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70 hover:opacity-90'
? 'border-purple-600 bg-purple-50 dark:bg-purple-900/20 shadow-lg shadow-purple-600/20 animate-pulseGlow'
: 'border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70'
}`}
role="listitem"
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? 'completed' : isCurrent ? 'in progress' : 'pending'}`}
>
<div className="flex items-center gap-3">
{/* Larger color swatch with better visibility */}
<div className="flex items-center gap-2.5">
{/* Color swatch */}
<div
className="w-8 h-8 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0 ring-2 ring-offset-2 ring-transparent dark:ring-offset-gray-800"
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
style={{
backgroundColor: block.threadHex,
...(isCurrent && { borderColor: '#2563eb', ringColor: '#93c5fd' })
...(isCurrent && { borderColor: '#9333ea' })
}}
title={`Thread color: ${block.threadHex}`}
aria-label={`Thread color ${block.threadHex}`}
@ -289,29 +229,29 @@ export function ProgressMonitor({
{/* Thread info */}
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100">
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
Thread {block.colorIndex + 1}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
<div className="text-[10px] text-gray-600 dark:text-gray-400 mt-0.5">
{block.stitchCount.toLocaleString()} stitches
</div>
</div>
{/* Status icon */}
{isCompleted ? (
<CheckCircleIcon className="w-6 h-6 text-green-600 flex-shrink-0" aria-label="Completed" />
<CheckCircleIcon className="w-5 h-5 text-green-600 flex-shrink-0" aria-label="Completed" />
) : isCurrent ? (
<ArrowRightIcon className="w-6 h-6 text-blue-600 flex-shrink-0 animate-pulse" aria-label="In progress" />
<ArrowRightIcon className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse" aria-label="In progress" />
) : (
<CircleStackIcon className="w-6 h-6 text-gray-400 flex-shrink-0" aria-label="Pending" />
<CircleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-label="Pending" />
)}
</div>
{/* Progress bar for current block */}
{isCurrent && (
<div className="mt-2 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 dark:bg-blue-500 transition-all duration-300 rounded-full"
className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full"
style={{ width: `${blockProgress}%` }}
role="progressbar"
aria-valuenow={Math.round(blockProgress)}
@ -327,7 +267,42 @@ export function ProgressMonitor({
</div>
</div>
)}
</div>
{/* Action buttons */}
<div className="flex gap-2 flex-wrap">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<button
onClick={onResumeSewing}
className="flex items-center gap-2 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</button>
)}
{/* Start Sewing - primary action */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<button
onClick={onStartSewing}
className="px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer"
aria-label="Start sewing the pattern"
>
Start Sewing
</button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<button
onClick={onStartMaskTrace}
className="px-3 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"
aria-label={isMaskTraceComplete ? 'Start mask trace again' : 'Start mask trace'}
>
{isMaskTraceComplete ? 'Trace Again' : 'Start Mask Trace'}
</button>
)}
</div>
</div>
);