Enhance UI/UX with loading states, animations, and pattern lock functionality

Loading State Improvements:
- Add SkeletonLoader component with pattern info, canvas, and connection skeletons
- Show loading spinner on file selection and during pattern upload
- Display upload progress with enhanced progress bar and percentage
- Add success confirmation message when upload completes
- Show thread color preview dots inline with pattern info (up to 5 colors)

Visual Polish & Animations:
- Add custom animations: fadeIn, slideInRight, pulseGlow, skeleton-loading
- Enhance all cards with subtle hover shadow effects
- Improve header with richer gradient (blue-600 → blue-700 → blue-800)
- Polish error messages with icons and improved layouts
- Enhance empty state with decorative patterns and feature highlights
- Add smooth transitions to all NextStepGuide states
- Current color block pulses with blue glow animation
- Color blocks have hover states for better interactivity

Pattern Upload & Lock Functionality:
- Hide upload button after pattern is uploaded (patternUploaded && uploadProgress === 100)
- Disable pattern dragging when uploaded with visual lock indicator
- Pattern position overlay shows amber background with lock icon when locked
- Pattern remains in canvas after deletion for re-editing and re-upload
- Delete pattern from cache when deleting from machine to prevent auto-resume
- Add LockClosedIcon to indicate locked pattern state

Pattern Management:
- Keep pattern data in UI after deletion for repositioning and re-uploading
- Clear machine-related state but preserve pattern visualization
- Reset upload progress and pattern uploaded state on deletion

🤖 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-06 19:28:24 +01:00
parent 3d26291d6d
commit c5ec118b95
9 changed files with 391 additions and 67 deletions

View file

@ -9,3 +9,74 @@
transform: translateX(100%);
}
}
/* Skeleton loading animation */
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Slide in from right */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Pulse glow effect */
@keyframes pulseGlow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
}
}
/* Success checkmark animation */
@keyframes checkmark {
0% {
stroke-dashoffset: 100;
}
100% {
stroke-dashoffset: 0;
}
}
/* Utility classes */
.animate-fadeIn {
animation: fadeIn 0.4s ease-out;
}
.animate-slideInRight {
animation: slideInRight 0.4s ease-out;
}
.animate-pulseGlow {
animation: pulseGlow 2s ease-in-out infinite;
}
.animate-skeleton {
animation: skeleton-loading 2s ease-in-out infinite;
}

View file

@ -66,7 +66,8 @@ function App() {
const handleDeletePattern = useCallback(async () => {
await machine.deletePattern();
setPatternUploaded(false);
setPesData(null);
// NOTE: We intentionally DON'T clear setPesData(null) here
// so the pattern remains visible in the canvas for re-editing and re-uploading
}, [machine]);
// Track pattern uploaded state based on machine status
@ -87,7 +88,7 @@ function App() {
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<header className="bg-gradient-to-r from-blue-600 to-blue-700 px-8 py-3 shadow-lg">
<header className="bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 px-8 py-3 shadow-lg border-b-2 border-blue-900/20">
<div className="max-w-[1600px] mx-auto flex items-center gap-8">
<h1 className="text-xl font-bold text-white whitespace-nowrap">SKiTCH Controller</h1>
@ -108,18 +109,38 @@ function App() {
<div className="flex-1 p-6 max-w-[1600px] w-full mx-auto">
{/* Global errors */}
{machine.error && (
<div className="bg-red-100 text-red-900 px-6 py-4 rounded-lg border-l-4 border-red-600 mb-6 shadow-md">
<strong>Error:</strong> {machine.error}
<div className="bg-red-50 text-red-900 px-6 py-4 rounded-lg border-l-4 border-red-600 mb-6 shadow-md hover:shadow-lg transition-shadow animate-fadeIn">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-red-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<strong className="font-semibold">Error:</strong> {machine.error}
</div>
</div>
</div>
)}
{pyodideError && (
<div className="bg-red-100 text-red-900 px-6 py-4 rounded-lg border-l-4 border-red-600 mb-6 shadow-md">
<strong>Python Error:</strong> {pyodideError}
<div className="bg-red-50 text-red-900 px-6 py-4 rounded-lg border-l-4 border-red-600 mb-6 shadow-md hover:shadow-lg transition-shadow animate-fadeIn">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-red-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<strong className="font-semibold">Python Error:</strong> {pyodideError}
</div>
</div>
</div>
)}
{!pyodideReady && !pyodideError && (
<div className="bg-blue-100 text-blue-900 px-6 py-4 rounded-lg border-l-4 border-blue-600 mb-6 shadow-md">
Initializing Python environment...
<div className="bg-blue-50 text-blue-900 px-6 py-4 rounded-lg border-l-4 border-blue-600 mb-6 shadow-md animate-fadeIn">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 animate-spin text-blue-600" 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 className="font-medium">Initializing Python environment...</span>
</div>
</div>
)}
@ -177,17 +198,48 @@ function App() {
machineInfo={machine.machineInfo}
initialPatternOffset={patternOffset}
onPatternOffsetChange={handlePatternOffsetChange}
patternUploaded={patternUploaded}
/>
) : (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="bg-white p-6 rounded-lg shadow-md animate-fadeIn">
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Pattern Preview</h2>
<div className="flex items-center justify-center h-[600px] bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
<div className="text-center">
<svg className="w-24 h-24 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<p className="text-gray-600 text-lg mb-2">No Pattern Loaded</p>
<p className="text-gray-500 text-sm">Connect to your machine and choose a PES file to begin</p>
<div className="flex items-center justify-center h-[600px] bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-300 relative overflow-hidden">
{/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5">
<div className="absolute top-10 left-10 w-32 h-32 border-4 border-gray-400 rounded-full"></div>
<div className="absolute bottom-10 right-10 w-40 h-40 border-4 border-gray-400 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 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" 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-blue-100 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" 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 text-xl font-semibold mb-2">No Pattern Loaded</h3>
<p className="text-gray-500 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">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-blue-400 rounded-full"></div>
<span>Drag to Position</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
<span>Zoom & Pan</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-purple-400 rounded-full"></div>
<span>Real-time Preview</span>
</div>
</div>
</div>
</div>
</div>

View file

@ -2,6 +2,8 @@ import { useState, useCallback } from 'react';
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';
interface FileUploadProps {
isConnected: boolean;
@ -74,7 +76,7 @@ export function FileUpload({
}, [pesData, displayFileName, onUpload, patternOffset]);
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="bg-white 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">Pattern File</h2>
<div>
@ -103,30 +105,81 @@ export function FileUpload({
className="hidden"
disabled={!pyodideReady || isLoading || patternUploaded}
/>
<label htmlFor="file-input" className={`inline-block px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm transition-all ${!pyodideReady || isLoading || patternUploaded ? 'opacity-50 cursor-not-allowed grayscale-[0.3]' : 'cursor-pointer hover:bg-gray-700 hover:shadow-md'}`}>
{isLoading ? 'Loading...' : !pyodideReady ? 'Initializing...' : patternUploaded ? 'Pattern Locked' : 'Choose PES File'}
<label
htmlFor="file-input"
className={`inline-flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg font-semibold text-sm transition-all ${
!pyodideReady || isLoading || patternUploaded
? 'opacity-50 cursor-not-allowed grayscale-[0.3]'
: 'cursor-pointer hover:bg-gray-700 hover:shadow-lg active:scale-[0.98]'
}`}
>
{isLoading ? (
<>
<svg className="w-5 h-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>
</>
) : !pyodideReady ? (
<>
<svg className="w-5 h-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>Initializing...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-5 h-5" />
<span>Pattern Locked</span>
</>
) : (
<span>Choose PES File</span>
)}
</label>
{pesData && (
<div className="mt-4">
{isLoading && <PatternInfoSkeleton />}
{!isLoading && pesData && (
<div className="mt-4 animate-fadeIn">
<h3 className="text-base font-semibold my-4">Pattern Information</h3>
<div className="bg-gray-50 p-4 rounded-lg space-y-3">
<div className="flex justify-between">
<div className="bg-gradient-to-br from-gray-50 to-gray-100 p-4 rounded-lg space-y-3 border border-gray-200 shadow-sm">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700">File Name:</span>
<span className="font-semibold text-gray-900">{displayFileName}</span>
<span className="font-semibold text-gray-900 text-right max-w-[200px] truncate" title={displayFileName}>
{displayFileName}
</span>
</div>
<div className="flex justify-between">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700">Pattern Size:</span>
<span className="font-semibold text-gray-900">
{((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">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700">Thread Colors:</span>
<span className="font-semibold text-gray-900">{pesData.colorCount}</span>
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{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 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 border border-gray-400 flex items-center justify-center text-[8px] font-bold text-gray-600">
+{pesData.colorCount - 5}
</div>
)}
</div>
</div>
</div>
<div className="flex justify-between">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700">Total Stitches:</span>
<span className="font-semibold text-gray-900">{pesData.stitchCount.toLocaleString()}</span>
</div>
@ -134,31 +187,59 @@ export function FileUpload({
</div>
)}
{pesData && canUploadPattern(machineStatus) && (
{pesData && canUploadPattern(machineStatus) && !patternUploaded && uploadProgress < 100 && (
<button
onClick={handleUpload}
disabled={!isConnected || uploadProgress > 0}
className="mt-4 px-6 py-2.5 bg-blue-600 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 active:bg-blue-800 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 disabled:hover:shadow-none disabled:active:scale-100"
className="mt-4 inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 active:bg-blue-800 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 disabled:hover:shadow-none disabled:active:scale-100"
aria-label={uploadProgress > 0 ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete` : 'Upload pattern to machine'}
>
{uploadProgress > 0
? `Uploading... ${uploadProgress.toFixed(0)}%`
: 'Upload to Machine'}
{uploadProgress > 0 ? (
<>
<svg className="w-5 h-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>Uploading... {uploadProgress.toFixed(0)}%</span>
</>
) : (
<>
<ArrowUpTrayIcon className="w-5 h-5" />
<span>Upload to Machine</span>
</>
)}
</button>
)}
{pesData && !canUploadPattern(machineStatus) && (
<div className="bg-yellow-100 text-yellow-800 px-4 py-3 rounded border border-yellow-200 my-4 font-medium">
<div className="bg-yellow-100 text-yellow-800 px-4 py-3 rounded-lg border border-yellow-200 my-4 font-medium animate-fadeIn">
Cannot upload pattern while machine is {getMachineStateCategory(machineStatus)}
</div>
)}
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="h-3 bg-gray-300 rounded-md overflow-hidden my-4 shadow-inner">
<div
className="h-full bg-gradient-to-r from-blue-600 to-blue-700 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: `${uploadProgress}%` }}
/>
<div className="mt-4 animate-fadeIn">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">Uploading to Machine</span>
<span className="text-sm font-bold text-blue-600">{uploadProgress.toFixed(1)}%</span>
</div>
<div className="h-3 bg-gray-300 rounded-full overflow-hidden shadow-inner relative">
<div
className="h-full bg-gradient-to-r from-blue-500 via-blue-600 to-blue-700 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 mt-2 text-center">Please wait while your pattern is being transferred...</p>
</div>
)}
{uploadProgress === 100 && (
<div className="mt-4 bg-green-50 border border-green-200 px-4 py-3 rounded-lg flex items-center gap-3 animate-fadeIn">
<CheckCircleIcon className="w-6 h-6 text-green-600 flex-shrink-0" />
<div>
<p className="text-sm font-semibold text-green-900">Upload Complete!</p>
<p className="text-xs text-green-700">Pattern successfully transferred to machine</p>
</div>
</div>
)}
</div>

View file

@ -62,7 +62,7 @@ export function MachineConnection({
const errorInfo = (isConnected && hasError(machineError)) ? getErrorDetails(machineError) : null;
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="bg-white 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">
<h2 className="text-xl font-semibold">Machine Connection</h2>
<div className="flex items-center gap-3">

View file

@ -28,7 +28,7 @@ export function NextStepGuide({
// Check if this is informational (like initialization steps) vs a real error
if (errorDetails?.isInformational) {
return (
<div className="bg-blue-50 border-l-4 border-blue-600 p-6 rounded-lg shadow-md">
<div className="bg-blue-50 border-l-4 border-blue-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-blue-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -56,7 +56,7 @@ export function NextStepGuide({
// Regular error display for actual errors
return (
<div className="bg-red-50 border-l-4 border-red-600 p-6 rounded-lg shadow-md">
<div className="bg-red-50 border-l-4 border-red-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<ExclamationTriangleIcon className="w-8 h-8 text-red-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -90,7 +90,7 @@ export function NextStepGuide({
// Determine what to show based on current state
if (!isConnected) {
return (
<div className="bg-blue-50 border-l-4 border-blue-600 p-6 rounded-lg shadow-md">
<div className="bg-blue-50 border-l-4 border-blue-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-blue-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -109,7 +109,7 @@ export function NextStepGuide({
if (!hasPattern) {
return (
<div className="bg-blue-50 border-l-4 border-blue-600 p-6 rounded-lg shadow-md">
<div className="bg-blue-50 border-l-4 border-blue-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-blue-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -129,7 +129,7 @@ export function NextStepGuide({
if (!patternUploaded) {
return (
<div className="bg-blue-50 border-l-4 border-blue-600 p-6 rounded-lg shadow-md">
<div className="bg-blue-50 border-l-4 border-blue-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-blue-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -169,7 +169,7 @@ export function NextStepGuide({
case MachineStatus.MASK_TRACE_LOCK_WAIT:
return (
<div className="bg-yellow-50 border-l-4 border-yellow-600 p-6 rounded-lg shadow-md">
<div className="bg-yellow-50 border-l-4 border-yellow-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-yellow-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -187,7 +187,7 @@ export function NextStepGuide({
case MachineStatus.MASK_TRACING:
return (
<div className="bg-cyan-50 border-l-4 border-cyan-600 p-6 rounded-lg shadow-md">
<div className="bg-cyan-50 border-l-4 border-cyan-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-cyan-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -206,7 +206,7 @@ export function NextStepGuide({
case MachineStatus.MASK_TRACE_COMPLETE:
case MachineStatus.SEWING_WAIT:
return (
<div className="bg-green-50 border-l-4 border-green-600 p-6 rounded-lg shadow-md">
<div className="bg-green-50 border-l-4 border-green-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-green-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -224,7 +224,7 @@ export function NextStepGuide({
case MachineStatus.SEWING:
return (
<div className="bg-cyan-50 border-l-4 border-cyan-600 p-6 rounded-lg shadow-md">
<div className="bg-cyan-50 border-l-4 border-cyan-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-cyan-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -242,7 +242,7 @@ export function NextStepGuide({
case MachineStatus.COLOR_CHANGE_WAIT:
return (
<div className="bg-yellow-50 border-l-4 border-yellow-600 p-6 rounded-lg shadow-md">
<div className="bg-yellow-50 border-l-4 border-yellow-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-yellow-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -262,7 +262,7 @@ export function NextStepGuide({
case MachineStatus.STOP:
case MachineStatus.SEWING_INTERRUPTION:
return (
<div className="bg-yellow-50 border-l-4 border-yellow-600 p-6 rounded-lg shadow-md">
<div className="bg-yellow-50 border-l-4 border-yellow-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-yellow-600 flex-shrink-0 mt-1" />
<div className="flex-1">
@ -280,7 +280,7 @@ export function NextStepGuide({
case MachineStatus.SEWING_COMPLETE:
return (
<div className="bg-green-50 border-l-4 border-green-600 p-6 rounded-lg shadow-md">
<div className="bg-green-50 border-l-4 border-green-600 p-6 rounded-lg shadow-md animate-fadeIn">
<div className="flex items-start gap-4">
<InformationCircleIcon className="w-8 h-8 text-green-600 flex-shrink-0 mt-1" />
<div className="flex-1">

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 } from '@heroicons/react/24/solid';
import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon } from '@heroicons/react/24/solid';
import type { PesPatternData } from '../utils/pystitchConverter';
import type { SewingProgress, MachineInfo } from '../types/machine';
import { calculateInitialScale } from '../utils/konvaRenderers';
@ -13,9 +13,10 @@ interface PatternCanvasProps {
machineInfo: MachineInfo | null;
initialPatternOffset?: { x: number; y: number };
onPatternOffsetChange?: (offsetX: number, offsetY: number) => void;
patternUploaded?: boolean;
}
export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPatternOffset, onPatternOffsetChange }: PatternCanvasProps) {
export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPatternOffset, onPatternOffsetChange, patternUploaded = false }: PatternCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
@ -169,7 +170,7 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
}, [onPatternOffsetChange]);
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="bg-white 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">Pattern Preview</h2>
<div className="relative w-full h-[600px] border border-gray-300 rounded bg-gray-50 overflow-hidden" ref={containerRef}>
{containerSize.width > 0 && (
@ -219,17 +220,17 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
{pesData && (
<Group
name="pattern-group"
draggable
draggable={!patternUploaded}
x={patternOffset.x}
y={patternOffset.y}
onDragEnd={handlePatternDragEnd}
onMouseEnter={(e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = 'move';
if (stage && !patternUploaded) stage.container().style.cursor = 'move';
}}
onMouseLeave={(e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = 'grab';
if (stage && !patternUploaded) stage.container().style.cursor = 'grab';
}}
>
<Stitches
@ -287,13 +288,23 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
</div>
{/* Pattern Offset Indicator */}
<div className="absolute bottom-20 right-5 bg-white/95 backdrop-blur-sm p-2.5 px-3.5 rounded-lg shadow-lg z-[11] min-w-[180px]">
<div className="text-[11px] font-semibold text-gray-600 uppercase tracking-wider mb-1">Pattern Position:</div>
<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 border-2 border-amber-300' : 'bg-white/95'
}`}>
<div className="flex items-center justify-between mb-1">
<div className="text-[11px] font-semibold text-gray-600 uppercase tracking-wider">Pattern Position:</div>
{patternUploaded && (
<div className="flex items-center gap-1 text-amber-600">
<LockClosedIcon className="w-3.5 h-3.5" />
<span className="text-[10px] font-bold">LOCKED</span>
</div>
)}
</div>
<div className="text-[13px] font-semibold text-blue-600 mb-1">
X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm
</div>
<div className="text-[10px] text-gray-600 italic">
Drag pattern to move Drag background to pan
{patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'}
</div>
</div>

View file

@ -110,7 +110,7 @@ export function ProgressMonitor({
};
return (
<div className="bg-white p-4 rounded-lg shadow-md">
<div className="bg-white 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">Sewing Progress</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -258,12 +258,12 @@ export function ProgressMonitor({
return (
<div
key={index}
className={`p-3 rounded-lg border-2 transition-all ${
className={`p-3 rounded-lg border-2 transition-all duration-300 ${
isCompleted
? 'border-green-600 bg-green-50'
? 'border-green-600 bg-green-50 hover:bg-green-100'
: isCurrent
? 'border-blue-600 bg-blue-50 shadow-lg shadow-blue-600/20'
: 'border-gray-200 bg-gray-50 opacity-70'
? 'border-blue-600 bg-blue-50 shadow-lg shadow-blue-600/20 animate-pulseGlow'
: 'border-gray-200 bg-gray-50 opacity-70 hover:opacity-90'
}`}
role="listitem"
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? 'completed' : isCurrent ? 'in progress' : 'pending'}`}

View file

@ -0,0 +1,89 @@
interface SkeletonLoaderProps {
className?: string;
variant?: 'text' | 'rect' | 'circle';
}
export function SkeletonLoader({ className = '', variant = 'rect' }: SkeletonLoaderProps) {
const baseClasses = 'animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%]';
const variantClasses = {
text: 'h-4 rounded',
rect: 'rounded-lg',
circle: 'rounded-full'
};
return (
<div className={`${baseClasses} ${variantClasses[variant]} ${className}`} />
);
}
export function PatternCanvasSkeleton() {
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="flex items-center justify-between mb-4 pb-2 border-b-2 border-gray-300">
<SkeletonLoader className="h-7 w-40" variant="text" />
</div>
<div className="relative w-full h-[600px] border border-gray-300 rounded bg-gray-50 overflow-hidden">
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<div className="relative w-24 h-24 mx-auto">
<SkeletonLoader className="w-24 h-24" variant="circle" />
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400 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>
</div>
</div>
<div className="space-y-2">
<SkeletonLoader className="h-5 w-48 mx-auto" variant="text" />
<SkeletonLoader className="h-4 w-64 mx-auto" variant="text" />
</div>
</div>
</div>
</div>
</div>
);
}
export function PatternInfoSkeleton() {
return (
<div className="mt-4">
<SkeletonLoader className="h-6 w-40 mb-4" variant="text" />
<div className="bg-gray-50 p-4 rounded-lg space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex justify-between">
<SkeletonLoader className="h-4 w-24" variant="text" />
<SkeletonLoader className="h-4 w-32" variant="text" />
</div>
))}
</div>
</div>
);
}
export function MachineConnectionSkeleton() {
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="flex items-center justify-between mb-4 pb-2 border-b-2 border-gray-300">
<SkeletonLoader className="h-7 w-48" variant="text" />
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<SkeletonLoader className="h-4 w-16" variant="text" />
<SkeletonLoader className="h-8 w-32 rounded-lg" />
</div>
<div className="bg-gray-50 p-4 rounded-lg space-y-2">
<div className="flex justify-between">
<SkeletonLoader className="h-4 w-20" variant="text" />
<SkeletonLoader className="h-4 w-24" variant="text" />
</div>
<div className="flex justify-between">
<SkeletonLoader className="h-4 w-24" variant="text" />
<SkeletonLoader className="h-4 w-32" variant="text" />
</div>
</div>
</div>
</div>
);
}

View file

@ -277,10 +277,30 @@ export function useBrotherMachine() {
try {
setError(null);
// Delete pattern from cache to prevent auto-resume
try {
const machineUuid = await service.getPatternUUID();
if (machineUuid) {
const uuidStr = uuidToString(machineUuid);
PatternCacheService.deletePattern(uuidStr);
console.log("[Cache] Deleted pattern with UUID:", uuidStr);
}
} catch (err) {
console.warn("[Cache] Failed to get UUID for cache deletion:", err);
}
await service.deletePattern();
// Clear machine-related state but keep pattern data in UI for re-editing
setPatternInfo(null);
setSewingProgress(null);
setUploadProgress(0); // Reset upload progress to allow new uploads
setResumeAvailable(false);
setResumeFileName(null);
// NOTE: We intentionally DON'T clear setResumedPattern(null)
// so the pattern remains visible in the canvas for re-editing
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete pattern");
@ -314,7 +334,7 @@ export function useBrotherMachine() {
const interval = setInterval(async () => {
await refreshStatus();
// Also refresh progress during sewing
// Refresh progress during sewing
if (machineStatus === MachineStatus.SEWING) {
await refreshProgress();
}