mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
Add isDeleting to destructured props with default value of false. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
313 lines
14 KiB
TypeScript
313 lines
14 KiB
TypeScript
import {
|
|
CheckCircleIcon,
|
|
ArrowRightIcon,
|
|
CircleStackIcon,
|
|
PlayIcon,
|
|
CheckBadgeIcon,
|
|
ClockIcon,
|
|
PauseCircleIcon,
|
|
ExclamationCircleIcon,
|
|
ChartBarIcon
|
|
} from '@heroicons/react/24/solid';
|
|
import type { PatternInfo, SewingProgress } from '../types/machine';
|
|
import { MachineStatus } from '../types/machine';
|
|
import type { PesPatternData } from '../utils/pystitchConverter';
|
|
import {
|
|
canStartSewing,
|
|
canStartMaskTrace,
|
|
canResumeSewing,
|
|
getStateVisualInfo
|
|
} from '../utils/machineStateHelpers';
|
|
|
|
interface ProgressMonitorProps {
|
|
machineStatus: MachineStatus;
|
|
patternInfo: PatternInfo | null;
|
|
sewingProgress: SewingProgress | null;
|
|
pesData: PesPatternData | null;
|
|
onStartMaskTrace: () => void;
|
|
onStartSewing: () => void;
|
|
onResumeSewing: () => void;
|
|
onDeletePattern: () => void;
|
|
isDeleting?: boolean;
|
|
}
|
|
|
|
export function ProgressMonitor({
|
|
machineStatus,
|
|
patternInfo,
|
|
sewingProgress,
|
|
pesData,
|
|
onStartMaskTrace,
|
|
onStartSewing,
|
|
onResumeSewing,
|
|
isDeleting = false,
|
|
}: ProgressMonitorProps) {
|
|
// State indicators
|
|
const isMaskTraceComplete = machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
|
|
|
|
const stateVisual = getStateVisualInfo(machineStatus);
|
|
|
|
const progressPercent = patternInfo
|
|
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
|
|
: 0;
|
|
|
|
// Calculate color block information from pesData
|
|
const colorBlocks = pesData ? (() => {
|
|
const blocks: Array<{
|
|
colorIndex: number;
|
|
threadHex: string;
|
|
startStitch: number;
|
|
endStitch: number;
|
|
stitchCount: number;
|
|
}> = [];
|
|
|
|
let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
|
|
let blockStartStitch = 0;
|
|
|
|
for (let i = 0; i < pesData.stitches.length; i++) {
|
|
const stitchColorIndex = pesData.stitches[i][3];
|
|
|
|
// When color changes, save the previous block
|
|
if (stitchColorIndex !== currentColorIndex || i === pesData.stitches.length - 1) {
|
|
const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
|
|
blocks.push({
|
|
colorIndex: currentColorIndex,
|
|
threadHex: pesData.threads[currentColorIndex]?.hex || '#000000',
|
|
startStitch: blockStartStitch,
|
|
endStitch: endStitch,
|
|
stitchCount: endStitch - blockStartStitch,
|
|
});
|
|
|
|
currentColorIndex = stitchColorIndex;
|
|
blockStartStitch = i;
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
})() : [];
|
|
|
|
// Determine current color block based on current stitch
|
|
const currentStitch = sewingProgress?.currentStitch || 0;
|
|
const currentBlockIndex = colorBlocks.findIndex(
|
|
block => currentStitch >= block.startStitch && currentStitch < block.endStitch
|
|
);
|
|
|
|
const stateIndicatorColors = {
|
|
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 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>
|
|
|
|
{/* Pattern Info */}
|
|
{patternInfo && (
|
|
<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 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 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>
|
|
)}
|
|
|
|
{/* Progress Bar */}
|
|
{sewingProgress && (
|
|
<div className="mb-3">
|
|
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
|
|
<div className="h-full bg-gradient-to-r from-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="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 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* State Visual Indicator */}
|
|
{patternInfo && (() => {
|
|
const iconMap = {
|
|
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-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-[10px] text-gray-600 dark:text-gray-400">{stateVisual.description}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Color Blocks */}
|
|
{colorBlocks.length > 0 && (
|
|
<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;
|
|
const isCurrent = index === currentBlockIndex;
|
|
|
|
// Calculate progress within current block
|
|
let blockProgress = 0;
|
|
if (isCurrent) {
|
|
blockProgress = ((currentStitch - block.startStitch) / block.stitchCount) * 100;
|
|
} else if (isCompleted) {
|
|
blockProgress = 100;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
|
|
isCompleted
|
|
? 'border-green-600 bg-green-50 dark:bg-green-900/20'
|
|
: isCurrent
|
|
? '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-2.5">
|
|
{/* Color swatch */}
|
|
<div
|
|
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
|
|
style={{
|
|
backgroundColor: block.threadHex,
|
|
...(isCurrent && { borderColor: '#9333ea' })
|
|
}}
|
|
title={`Thread color: ${block.threadHex}`}
|
|
aria-label={`Thread color ${block.threadHex}`}
|
|
/>
|
|
|
|
{/* Thread info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
|
|
Thread {block.colorIndex + 1}
|
|
</div>
|
|
<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-5 h-5 text-green-600 flex-shrink-0" aria-label="Completed" />
|
|
) : isCurrent ? (
|
|
<ArrowRightIcon className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse" aria-label="In progress" />
|
|
) : (
|
|
<CircleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-label="Pending" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress bar for current block */}
|
|
{isCurrent && (
|
|
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full"
|
|
style={{ width: `${blockProgress}%` }}
|
|
role="progressbar"
|
|
aria-valuenow={Math.round(blockProgress)}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-label={`${Math.round(blockProgress)}% complete`}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{/* Resume has highest priority when available */}
|
|
{canResumeSewing(machineStatus) && (
|
|
<button
|
|
onClick={onResumeSewing}
|
|
disabled={isDeleting}
|
|
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
aria-label="Resume sewing the current pattern"
|
|
>
|
|
<PlayIcon className="w-3.5 h-3.5" />
|
|
Resume Sewing
|
|
</button>
|
|
)}
|
|
|
|
{/* Start Sewing - primary action */}
|
|
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
|
|
<button
|
|
onClick={onStartSewing}
|
|
disabled={isDeleting}
|
|
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="Start sewing the pattern"
|
|
>
|
|
Start Sewing
|
|
</button>
|
|
)}
|
|
|
|
{/* Start Mask Trace - secondary action */}
|
|
{canStartMaskTrace(machineStatus) && (
|
|
<button
|
|
onClick={onStartMaskTrace}
|
|
disabled={isDeleting}
|
|
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
aria-label={isMaskTraceComplete ? 'Start mask trace again' : 'Start mask trace'}
|
|
>
|
|
{isMaskTraceComplete ? 'Trace Again' : 'Start Mask Trace'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|