diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx deleted file mode 100644 index f89990d..0000000 --- a/src/components/FileUpload.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { useState, useCallback } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; -import { useMachineUploadStore } from "../stores/useMachineUploadStore"; -import { useMachineCacheStore } from "../stores/useMachineCacheStore"; -import { usePatternStore } from "../stores/usePatternStore"; -import { useUIStore } from "../stores/useUIStore"; -import type { PesPatternData } from "../formats/import/pesImporter"; -import { - canUploadPattern, - getMachineStateCategory, -} from "../utils/machineStateHelpers"; -import { - useFileUpload, - usePatternRotationUpload, - usePatternValidation, -} from "@/hooks"; -import { PatternInfoSkeleton } from "./SkeletonLoader"; -import { PatternInfo } from "./PatternInfo"; -import { - ArrowUpTrayIcon, - CheckCircleIcon, - DocumentTextIcon, - FolderOpenIcon, -} from "@heroicons/react/24/solid"; -import { createFileService } from "../platform"; -import type { IFileService } from "../platform/interfaces/IFileService"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Progress } from "@/components/ui/progress"; -import { Loader2 } from "lucide-react"; -import { cn } from "@/lib/utils"; - -export function FileUpload() { - // Machine store - const { isConnected, machineStatus, machineInfo } = useMachineStore( - useShallow((state) => ({ - isConnected: state.isConnected, - machineStatus: state.machineStatus, - machineInfo: state.machineInfo, - })), - ); - - // Machine upload store - const { uploadProgress, isUploading, uploadPattern } = useMachineUploadStore( - useShallow((state) => ({ - uploadProgress: state.uploadProgress, - isUploading: state.isUploading, - uploadPattern: state.uploadPattern, - })), - ); - - // Machine cache store - const { resumeAvailable, resumeFileName } = useMachineCacheStore( - useShallow((state) => ({ - resumeAvailable: state.resumeAvailable, - resumeFileName: state.resumeFileName, - })), - ); - - // Pattern store - const { - pesData: pesDataProp, - currentFileName, - patternOffset, - patternRotation, - setPattern, - setUploadedPattern, - } = usePatternStore( - useShallow((state) => ({ - pesData: state.pesData, - currentFileName: state.currentFileName, - patternOffset: state.patternOffset, - patternRotation: state.patternRotation, - setPattern: state.setPattern, - setUploadedPattern: state.setUploadedPattern, - })), - ); - - // Derived state: pattern is uploaded if machine has pattern info - const patternUploaded = usePatternUploaded(); - - // UI store - const { - pyodideReady, - pyodideProgress, - pyodideLoadingStep, - initializePyodide, - } = useUIStore( - useShallow((state) => ({ - pyodideReady: state.pyodideReady, - pyodideProgress: state.pyodideProgress, - pyodideLoadingStep: state.pyodideLoadingStep, - initializePyodide: state.initializePyodide, - })), - ); - const [localPesData, setLocalPesData] = useState(null); - const [fileName, setFileName] = useState(""); - const [fileService] = useState(() => createFileService()); - - // Use prop pesData if available (from cached pattern), otherwise use local state - const pesData = pesDataProp || localPesData; - // Use currentFileName from App state, or local fileName, or resumeFileName for display - const displayFileName = currentFileName || fileName || resumeFileName || ""; - - // File upload hook - handles file selection and conversion - const { isLoading, handleFileChange } = useFileUpload({ - fileService, - pyodideReady, - initializePyodide, - onFileLoaded: useCallback( - (data: PesPatternData, name: string) => { - setLocalPesData(data); - setFileName(name); - setPattern(data, name); - }, - [setPattern], - ), - }); - - // Pattern rotation and upload hook - handles rotation transformation - const { handleUpload: handlePatternUpload } = usePatternRotationUpload({ - uploadPattern, - setUploadedPattern, - }); - - // Wrapper to call upload with current pattern data - const handleUpload = useCallback(async () => { - if (pesData && displayFileName) { - await handlePatternUpload( - pesData, - displayFileName, - patternOffset, - patternRotation, - ); - } - }, [ - pesData, - displayFileName, - patternOffset, - patternRotation, - handlePatternUpload, - ]); - - // Pattern validation hook - checks if pattern fits in hoop - const boundsCheck = usePatternValidation({ - pesData, - machineInfo, - patternOffset, - patternRotation, - }); - - const borderColor = pesData - ? "border-secondary-600 dark:border-secondary-500" - : "border-gray-400 dark:border-gray-600"; - const iconColor = pesData - ? "text-secondary-600 dark:text-secondary-400" - : "text-gray-600 dark:text-gray-400"; - - return ( - - -
- -
-

- Pattern File -

- {pesData && displayFileName ? ( -

- {displayFileName} -

- ) : ( -

- No pattern loaded -

- )} -
-
- - {resumeAvailable && resumeFileName && ( -
-

- Cached: "{resumeFileName}" -

-
- )} - - {isLoading && } - - {!isLoading && pesData && ( -
- -
- )} - -
- 0 && !patternUploaded) - } - /> - - - {pesData && - canUploadPattern(machineStatus) && - !patternUploaded && - uploadProgress < 100 && ( - - )} -
- - {/* Pyodide initialization progress indicator - shown when initializing or waiting */} - {!pyodideReady && pyodideProgress > 0 && ( -
-
- - {isLoading && !pyodideReady - ? "Please wait - initializing Python environment..." - : pyodideLoadingStep || "Initializing Python environment..."} - - - {pyodideProgress.toFixed(0)}% - -
- -

- {isLoading && !pyodideReady - ? "File dialog will open automatically when ready" - : "This only happens once on first use"} -

-
- )} - - {/* Error/warning messages with smooth transition - placed after buttons */} -
- {pesData && !canUploadPattern(machineStatus) && ( - - - Cannot upload while {getMachineStateCategory(machineStatus)} - - - )} - - {pesData && boundsCheck.error && ( - - - Pattern too large: {boundsCheck.error} - - - )} -
- - {isUploading && uploadProgress < 100 && ( -
-
- - Uploading - - - {uploadProgress > 0 - ? uploadProgress.toFixed(1) + "%" - : "Starting..."} - -
- -
- )} -
-
- ); -} diff --git a/src/components/FileUpload/BoundsValidator.tsx b/src/components/FileUpload/BoundsValidator.tsx new file mode 100644 index 0000000..431f033 --- /dev/null +++ b/src/components/FileUpload/BoundsValidator.tsx @@ -0,0 +1,53 @@ +/** + * BoundsValidator Component + * + * Renders error/warning messages with smooth transitions + */ + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { MachineStatus } from "../../types/machine"; +import { + canUploadPattern, + getMachineStateCategory, +} from "../../utils/machineStateHelpers"; +import type { PesPatternData } from "../../formats/import/pesImporter"; + +interface BoundsValidatorProps { + pesData: PesPatternData | null; + machineStatus: MachineStatus; + boundsError: string | null; +} + +export function BoundsValidator({ + pesData, + machineStatus, + boundsError, +}: BoundsValidatorProps) { + const hasError = pesData && (boundsError || !canUploadPattern(machineStatus)); + + return ( +
+ {pesData && !canUploadPattern(machineStatus) && ( + + + Cannot upload while {getMachineStateCategory(machineStatus)} + + + )} + + {pesData && boundsError && ( + + + Pattern too large: {boundsError} + + + )} +
+ ); +} diff --git a/src/components/FileUpload/FileSelector.tsx b/src/components/FileUpload/FileSelector.tsx new file mode 100644 index 0000000..725af02 --- /dev/null +++ b/src/components/FileUpload/FileSelector.tsx @@ -0,0 +1,92 @@ +/** + * FileSelector Component + * + * Renders file input and selection button, handles native vs web file selection + */ + +import { FolderOpenIcon, CheckCircleIcon } from "@heroicons/react/24/solid"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { IFileService } from "../../platform/interfaces/IFileService"; + +interface FileSelectorProps { + fileService: IFileService; + isLoading: boolean; + isDisabled: boolean; + onFileChange: (event?: React.ChangeEvent) => Promise; + displayFileName: string; + patternUploaded: boolean; +} + +export function FileSelector({ + fileService, + isLoading, + isDisabled, + onFileChange, + patternUploaded, +}: FileSelectorProps) { + const hasNativeDialogs = fileService.hasNativeDialogs(); + + return ( + <> + + + + ); +} diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx new file mode 100644 index 0000000..da0ec2e --- /dev/null +++ b/src/components/FileUpload/FileUpload.tsx @@ -0,0 +1,259 @@ +/** + * FileUpload Component + * + * Orchestrates file upload UI with file selection, Pyodide initialization, pattern upload, and validation + */ + +import { useState, useCallback } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { + useMachineStore, + usePatternUploaded, +} from "../../stores/useMachineStore"; +import { useMachineUploadStore } from "../../stores/useMachineUploadStore"; +import { useMachineCacheStore } from "../../stores/useMachineCacheStore"; +import { usePatternStore } from "../../stores/usePatternStore"; +import { useUIStore } from "../../stores/useUIStore"; +import type { PesPatternData } from "../../formats/import/pesImporter"; +import { + useFileUpload, + usePatternRotationUpload, + usePatternValidation, +} from "@/hooks"; +import { getDisplayFilename } from "../../utils/displayFilename"; +import { PatternInfoSkeleton } from "../SkeletonLoader"; +import { PatternInfo } from "../PatternInfo"; +import { DocumentTextIcon } from "@heroicons/react/24/solid"; +import { createFileService } from "../../platform"; +import type { IFileService } from "../../platform/interfaces/IFileService"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { FileSelector } from "./FileSelector"; +import { PyodideProgress } from "./PyodideProgress"; +import { UploadButton } from "./UploadButton"; +import { UploadProgress } from "./UploadProgress"; +import { BoundsValidator } from "./BoundsValidator"; + +export function FileUpload() { + // Machine store + const { isConnected, machineStatus, machineInfo } = useMachineStore( + useShallow((state) => ({ + isConnected: state.isConnected, + machineStatus: state.machineStatus, + machineInfo: state.machineInfo, + })), + ); + + // Machine upload store + const { uploadProgress, isUploading, uploadPattern } = useMachineUploadStore( + useShallow((state) => ({ + uploadProgress: state.uploadProgress, + isUploading: state.isUploading, + uploadPattern: state.uploadPattern, + })), + ); + + // Machine cache store + const { resumeAvailable, resumeFileName } = useMachineCacheStore( + useShallow((state) => ({ + resumeAvailable: state.resumeAvailable, + resumeFileName: state.resumeFileName, + })), + ); + + // Pattern store + const { + pesData: pesDataProp, + currentFileName, + patternOffset, + patternRotation, + setPattern, + setUploadedPattern, + } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + currentFileName: state.currentFileName, + patternOffset: state.patternOffset, + patternRotation: state.patternRotation, + setPattern: state.setPattern, + setUploadedPattern: state.setUploadedPattern, + })), + ); + + // Derived state: pattern is uploaded if machine has pattern info + const patternUploaded = usePatternUploaded(); + + // UI store + const { + pyodideReady, + pyodideProgress, + pyodideLoadingStep, + initializePyodide, + } = useUIStore( + useShallow((state) => ({ + pyodideReady: state.pyodideReady, + pyodideProgress: state.pyodideProgress, + pyodideLoadingStep: state.pyodideLoadingStep, + initializePyodide: state.initializePyodide, + })), + ); + + const [localPesData, setLocalPesData] = useState(null); + const [fileName, setFileName] = useState(""); + const [fileService] = useState(() => createFileService()); + + // Use prop pesData if available (from cached pattern), otherwise use local state + const pesData = pesDataProp || localPesData; + // Use currentFileName from App state, or local fileName, or resumeFileName for display + const displayFileName = getDisplayFilename({ + currentFileName, + localFileName: fileName, + resumeFileName, + }); + + // File upload hook - handles file selection and conversion + const { isLoading, handleFileChange } = useFileUpload({ + fileService, + pyodideReady, + initializePyodide, + onFileLoaded: useCallback( + (data: PesPatternData, name: string) => { + setLocalPesData(data); + setFileName(name); + setPattern(data, name); + }, + [setPattern], + ), + }); + + // Pattern rotation and upload hook - handles rotation transformation + const { handleUpload: handlePatternUpload } = usePatternRotationUpload({ + uploadPattern, + setUploadedPattern, + }); + + // Wrapper to call upload with current pattern data + const handleUpload = useCallback(async () => { + if (pesData && displayFileName) { + await handlePatternUpload( + pesData, + displayFileName, + patternOffset, + patternRotation, + ); + } + }, [ + pesData, + displayFileName, + patternOffset, + patternRotation, + handlePatternUpload, + ]); + + // Pattern validation hook - checks if pattern fits in hoop + const boundsCheck = usePatternValidation({ + pesData, + machineInfo, + patternOffset, + patternRotation, + }); + + const borderColor = pesData + ? "border-secondary-600 dark:border-secondary-500" + : "border-gray-400 dark:border-gray-600"; + const iconColor = pesData + ? "text-secondary-600 dark:text-secondary-400" + : "text-gray-600 dark:text-gray-400"; + + const isSelectorDisabled = + isLoading || + patternUploaded || + isUploading || + (uploadProgress > 0 && !patternUploaded); + + return ( + + +
+ +
+

+ Pattern File +

+ {pesData && displayFileName ? ( +

+ {displayFileName} +

+ ) : ( +

+ No pattern loaded +

+ )} +
+
+ + {resumeAvailable && resumeFileName && ( +
+

+ Cached: "{resumeFileName}" +

+
+ )} + + {isLoading && } + + {!isLoading && pesData && ( +
+ +
+ )} + +
+ + + +
+ + + + + + +
+
+ ); +} diff --git a/src/components/FileUpload/PyodideProgress.tsx b/src/components/FileUpload/PyodideProgress.tsx new file mode 100644 index 0000000..e8c3868 --- /dev/null +++ b/src/components/FileUpload/PyodideProgress.tsx @@ -0,0 +1,44 @@ +/** + * PyodideProgress Component + * + * Renders Pyodide initialization progress indicator + */ + +import { Progress } from "@/components/ui/progress"; + +interface PyodideProgressProps { + pyodideReady: boolean; + pyodideProgress: number; + pyodideLoadingStep: string | null; + isFileLoading: boolean; +} + +export function PyodideProgress({ + pyodideReady, + pyodideProgress, + pyodideLoadingStep, + isFileLoading, +}: PyodideProgressProps) { + if (pyodideReady || pyodideProgress === 0) return null; + + return ( +
+
+ + {isFileLoading + ? "Please wait - initializing Python environment..." + : pyodideLoadingStep || "Initializing Python environment..."} + + + {pyodideProgress.toFixed(0)}% + +
+ +

+ {isFileLoading + ? "File dialog will open automatically when ready" + : "This only happens once on first use"} +

+
+ ); +} diff --git a/src/components/FileUpload/UploadButton.tsx b/src/components/FileUpload/UploadButton.tsx new file mode 100644 index 0000000..22d7912 --- /dev/null +++ b/src/components/FileUpload/UploadButton.tsx @@ -0,0 +1,69 @@ +/** + * UploadButton Component + * + * Renders upload button with progress, conditionally shown based on machine state + */ + +import { ArrowUpTrayIcon } from "@heroicons/react/24/solid"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { MachineStatus } from "../../types/machine"; +import { canUploadPattern } from "../../utils/machineStateHelpers"; +import type { PesPatternData } from "../../formats/import/pesImporter"; + +interface UploadButtonProps { + pesData: PesPatternData | null; + machineStatus: MachineStatus; + isConnected: boolean; + isUploading: boolean; + uploadProgress: number; + boundsFits: boolean; + boundsError: string | null; + onUpload: () => Promise; + patternUploaded: boolean; +} + +export function UploadButton({ + pesData, + machineStatus, + isConnected, + isUploading, + uploadProgress, + boundsFits, + boundsError, + onUpload, + patternUploaded, +}: UploadButtonProps) { + const shouldShow = + pesData && + canUploadPattern(machineStatus) && + !patternUploaded && + uploadProgress < 100; + + if (!shouldShow) return null; + + return ( + + ); +} diff --git a/src/components/FileUpload/UploadProgress.tsx b/src/components/FileUpload/UploadProgress.tsx new file mode 100644 index 0000000..d41babb --- /dev/null +++ b/src/components/FileUpload/UploadProgress.tsx @@ -0,0 +1,36 @@ +/** + * UploadProgress Component + * + * Renders upload progress bar + */ + +import { Progress } from "@/components/ui/progress"; + +interface UploadProgressProps { + isUploading: boolean; + uploadProgress: number; +} + +export function UploadProgress({ + isUploading, + uploadProgress, +}: UploadProgressProps) { + if (!isUploading || uploadProgress >= 100) return null; + + return ( +
+
+ + Uploading + + + {uploadProgress > 0 ? uploadProgress.toFixed(1) + "%" : "Starting..."} + +
+ +
+ ); +} diff --git a/src/components/FileUpload/index.ts b/src/components/FileUpload/index.ts new file mode 100644 index 0000000..61f5b4a --- /dev/null +++ b/src/components/FileUpload/index.ts @@ -0,0 +1,5 @@ +/** + * FileUpload component barrel export + */ + +export { FileUpload } from "./FileUpload"; diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx deleted file mode 100644 index 66f6ef3..0000000 --- a/src/components/ProgressMonitor.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { useMemo } from "react"; -import { useAutoScroll } from "@/hooks"; -import { useShallow } from "zustand/react/shallow"; -import { useMachineStore } from "../stores/useMachineStore"; -import { usePatternStore } from "../stores/usePatternStore"; -import { - CheckCircleIcon, - ArrowRightIcon, - CircleStackIcon, - PlayIcon, - ChartBarIcon, - ArrowPathIcon, -} from "@heroicons/react/24/solid"; -import { MachineStatus } from "../types/machine"; -import { - canStartSewing, - canStartMaskTrace, - canResumeSewing, -} from "../utils/machineStateHelpers"; -import { calculatePatternTime } from "../utils/timeCalculation"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; -import { ScrollArea } from "@/components/ui/scroll-area"; - -export function ProgressMonitor() { - // Machine store - const { - machineStatus, - patternInfo, - sewingProgress, - isDeleting, - startMaskTrace, - startSewing, - resumeSewing, - } = useMachineStore( - useShallow((state) => ({ - machineStatus: state.machineStatus, - patternInfo: state.patternInfo, - sewingProgress: state.sewingProgress, - isDeleting: state.isDeleting, - startMaskTrace: state.startMaskTrace, - startSewing: state.startSewing, - resumeSewing: state.resumeSewing, - })), - ); - - // Pattern store - const pesData = usePatternStore((state) => state.pesData); - const uploadedPesData = usePatternStore((state) => state.uploadedPesData); - const displayPattern = uploadedPesData || pesData; - - // State indicators - const isMaskTraceComplete = - machineStatus === MachineStatus.MASK_TRACE_COMPLETE; - - // Use PEN stitch count as fallback when machine reports 0 total stitches - const totalStitches = patternInfo - ? patternInfo.totalStitches === 0 && displayPattern?.penStitches - ? displayPattern.penStitches.stitches.length - : patternInfo.totalStitches - : 0; - - const progressPercent = - totalStitches > 0 - ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100 - : 0; - - // Calculate color block information from decoded penStitches - const colorBlocks = useMemo(() => { - if (!displayPattern || !displayPattern.penStitches) return []; - - const blocks: Array<{ - colorIndex: number; - threadHex: string; - startStitch: number; - endStitch: number; - stitchCount: number; - threadCatalogNumber: string | null; - threadBrand: string | null; - threadDescription: string | null; - threadChart: string | null; - }> = []; - - // Use the pre-computed color blocks from decoded PEN data - for (const penBlock of displayPattern.penStitches.colorBlocks) { - const thread = displayPattern.threads[penBlock.colorIndex]; - blocks.push({ - colorIndex: penBlock.colorIndex, - threadHex: thread?.hex || "#000000", - threadCatalogNumber: thread?.catalogNumber ?? null, - threadBrand: thread?.brand ?? null, - threadDescription: thread?.description ?? null, - threadChart: thread?.chart ?? null, - startStitch: penBlock.startStitchIndex, - endStitch: penBlock.endStitchIndex, - stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex, - }); - } - - return blocks; - }, [displayPattern]); - - // Determine current color block based on current stitch - const currentStitch = sewingProgress?.currentStitch || 0; - const currentBlockIndex = colorBlocks.findIndex( - (block) => - currentStitch >= block.startStitch && currentStitch < block.endStitch, - ); - - // Calculate time based on color blocks (matches Brother app calculation) - const { totalMinutes, elapsedMinutes } = useMemo(() => { - if (colorBlocks.length === 0) { - return { totalMinutes: 0, elapsedMinutes: 0 }; - } - const result = calculatePatternTime(colorBlocks, currentStitch); - return { - totalMinutes: result.totalMinutes, - elapsedMinutes: result.elapsedMinutes, - }; - }, [colorBlocks, currentStitch]); - - // Auto-scroll to current block - const currentBlockRef = useAutoScroll(currentBlockIndex); - - return ( - - -
- -
- Sewing Progress - {sewingProgress && ( - - {progressPercent.toFixed(1)}% complete - - )} -
-
-
- - {/* Pattern Info */} - {patternInfo && ( -
-
- - Total Stitches - - - {totalStitches.toLocaleString()} - -
-
- - Total Time - - - {totalMinutes} min - -
-
- - Speed - - - {patternInfo.speed} spm - -
-
- )} - - {/* Progress Bar */} - {sewingProgress && ( -
- - -
-
- - Current Stitch - - - {sewingProgress.currentStitch.toLocaleString()} /{" "} - {totalStitches.toLocaleString()} - -
-
- - Time - - - {elapsedMinutes} / {totalMinutes} min - -
-
-
- )} - - {/* Color Blocks */} - {colorBlocks.length > 0 && ( -
-

- Color Blocks -

- -
- {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 ( -
-
- {/* Color swatch */} -
- - {/* Thread info */} -
-
- Thread {block.colorIndex + 1} - {(block.threadBrand || - block.threadChart || - block.threadDescription || - block.threadCatalogNumber) && ( - - {" "} - ( - {(() => { - // Primary metadata: brand and catalog number - const primaryMetadata = [ - block.threadBrand, - block.threadCatalogNumber - ? `#${block.threadCatalogNumber}` - : null, - ] - .filter(Boolean) - .join(" "); - - // Secondary metadata: chart and description - // Only show chart if it's different from catalogNumber - const secondaryMetadata = [ - block.threadChart && - block.threadChart !== - block.threadCatalogNumber - ? block.threadChart - : null, - block.threadDescription, - ] - .filter(Boolean) - .join(" "); - - return [primaryMetadata, secondaryMetadata] - .filter(Boolean) - .join(" • "); - })()} - ) - - )} -
-
- {block.stitchCount.toLocaleString()} stitches -
-
- - {/* Status icon */} - {isCompleted ? ( - - ) : isCurrent ? ( - - ) : ( - - )} -
- - {/* Progress bar for current block */} - {isCurrent && ( - - )} -
- ); - })} -
- -
- )} - - {/* Action buttons */} -
- {/* Resume has highest priority when available */} - {canResumeSewing(machineStatus) && ( - - )} - - {/* Start Sewing - primary action, takes more space */} - {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && ( - - )} - - {/* Start Mask Trace - secondary action */} - {canStartMaskTrace(machineStatus) && ( - - )} -
- - - ); -} diff --git a/src/components/ProgressMonitor/ColorBlockItem.tsx b/src/components/ProgressMonitor/ColorBlockItem.tsx new file mode 100644 index 0000000..a85a57a --- /dev/null +++ b/src/components/ProgressMonitor/ColorBlockItem.tsx @@ -0,0 +1,115 @@ +/** + * ColorBlockItem Component + * + * Renders an individual color block card with thread metadata, stitch count, status icon, and progress + */ + +import { forwardRef } from "react"; +import { + CheckCircleIcon, + ArrowRightIcon, + CircleStackIcon, +} from "@heroicons/react/24/solid"; +import { Progress } from "@/components/ui/progress"; +import { formatThreadMetadata } from "../../utils/threadMetadata"; +import type { ColorBlock } from "./types"; + +interface ColorBlockItemProps { + block: ColorBlock; + index: number; + currentStitch: number; + isCurrent: boolean; + isCompleted: boolean; +} + +export const ColorBlockItem = forwardRef( + ({ block, index, currentStitch, isCurrent, isCompleted }, ref) => { + // Calculate progress within current block + let blockProgress = 0; + if (isCurrent) { + blockProgress = + ((currentStitch - block.startStitch) / block.stitchCount) * 100; + } else if (isCompleted) { + blockProgress = 100; + } + + const hasMetadata = + block.threadBrand || + block.threadChart || + block.threadDescription || + block.threadCatalogNumber; + + return ( +
+
+ {/* Color swatch */} +
+ + {/* Thread info */} +
+
+ Thread {block.colorIndex + 1} + {hasMetadata && ( + + {" "} + ({formatThreadMetadata(block)}) + + )} +
+
+ {block.stitchCount.toLocaleString()} stitches +
+
+ + {/* Status icon */} + {isCompleted ? ( + + ) : isCurrent ? ( + + ) : ( + + )} +
+ + {/* Progress bar for current block */} + {isCurrent && ( + + )} +
+ ); + }, +); + +ColorBlockItem.displayName = "ColorBlockItem"; diff --git a/src/components/ProgressMonitor/ColorBlockList.tsx b/src/components/ProgressMonitor/ColorBlockList.tsx new file mode 100644 index 0000000..29a22aa --- /dev/null +++ b/src/components/ProgressMonitor/ColorBlockList.tsx @@ -0,0 +1,53 @@ +/** + * ColorBlockList Component + * + * Container for the scrollable list of color blocks + */ + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ColorBlockItem } from "./ColorBlockItem"; +import type { ColorBlock } from "./types"; + +interface ColorBlockListProps { + colorBlocks: ColorBlock[]; + currentStitch: number; + currentBlockIndex: number; + currentBlockRef: React.RefObject; +} + +export function ColorBlockList({ + colorBlocks, + currentStitch, + currentBlockIndex, + currentBlockRef, +}: ColorBlockListProps) { + if (colorBlocks.length === 0) return null; + + return ( +
+

+ Color Blocks +

+ +
+ {colorBlocks.map((block, index) => { + const isCompleted = currentStitch >= block.endStitch; + const isCurrent = index === currentBlockIndex; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/ProgressMonitor/ProgressActions.tsx b/src/components/ProgressMonitor/ProgressActions.tsx new file mode 100644 index 0000000..401a9f2 --- /dev/null +++ b/src/components/ProgressMonitor/ProgressActions.tsx @@ -0,0 +1,78 @@ +/** + * ProgressActions Component + * + * Renders action buttons (Resume Sewing, Start Sewing, Start Mask Trace) + */ + +import { PlayIcon, ArrowPathIcon } from "@heroicons/react/24/solid"; +import { Button } from "@/components/ui/button"; +import { MachineStatus } from "../../types/machine"; +import { + canStartSewing, + canStartMaskTrace, + canResumeSewing, +} from "../../utils/machineStateHelpers"; + +interface ProgressActionsProps { + machineStatus: MachineStatus; + isDeleting: boolean; + isMaskTraceComplete: boolean; + onResumeSewing: () => void; + onStartSewing: () => void; + onStartMaskTrace: () => void; +} + +export function ProgressActions({ + machineStatus, + isDeleting, + isMaskTraceComplete, + onResumeSewing, + onStartSewing, + onStartMaskTrace, +}: ProgressActionsProps) { + return ( +
+ {/* Resume has highest priority when available */} + {canResumeSewing(machineStatus) && ( + + )} + + {/* Start Sewing - primary action, takes more space */} + {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && ( + + )} + + {/* Start Mask Trace - secondary action */} + {canStartMaskTrace(machineStatus) && ( + + )} +
+ ); +} diff --git a/src/components/ProgressMonitor/ProgressMonitor.tsx b/src/components/ProgressMonitor/ProgressMonitor.tsx new file mode 100644 index 0000000..b15e1dc --- /dev/null +++ b/src/components/ProgressMonitor/ProgressMonitor.tsx @@ -0,0 +1,174 @@ +/** + * ProgressMonitor Component + * + * Orchestrates progress monitoring UI with stats, progress bar, color blocks, and action buttons + */ + +import { useMemo } from "react"; +import { useAutoScroll } from "@/hooks"; +import { useShallow } from "zustand/react/shallow"; +import { useMachineStore } from "../../stores/useMachineStore"; +import { usePatternStore } from "../../stores/usePatternStore"; +import { ChartBarIcon } from "@heroicons/react/24/solid"; +import { MachineStatus } from "../../types/machine"; +import { calculatePatternTime } from "../../utils/timeCalculation"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { ProgressStats } from "./ProgressStats"; +import { ProgressSection } from "./ProgressSection"; +import { ColorBlockList } from "./ColorBlockList"; +import { ProgressActions } from "./ProgressActions"; +import type { ColorBlock } from "./types"; + +export function ProgressMonitor() { + // Machine store + const { + machineStatus, + patternInfo, + sewingProgress, + isDeleting, + startMaskTrace, + startSewing, + resumeSewing, + } = useMachineStore( + useShallow((state) => ({ + machineStatus: state.machineStatus, + patternInfo: state.patternInfo, + sewingProgress: state.sewingProgress, + isDeleting: state.isDeleting, + startMaskTrace: state.startMaskTrace, + startSewing: state.startSewing, + resumeSewing: state.resumeSewing, + })), + ); + + // Pattern store + const pesData = usePatternStore((state) => state.pesData); + const uploadedPesData = usePatternStore((state) => state.uploadedPesData); + const displayPattern = uploadedPesData || pesData; + + // State indicators + const isMaskTraceComplete = + machineStatus === MachineStatus.MASK_TRACE_COMPLETE; + + // Use PEN stitch count as fallback when machine reports 0 total stitches + const totalStitches = patternInfo + ? patternInfo.totalStitches === 0 && displayPattern?.penStitches + ? displayPattern.penStitches.stitches.length + : patternInfo.totalStitches + : 0; + + const progressPercent = + totalStitches > 0 + ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100 + : 0; + + // Calculate color block information from decoded penStitches + const colorBlocks = useMemo(() => { + if (!displayPattern || !displayPattern.penStitches) return []; + + const blocks: ColorBlock[] = []; + + // Use the pre-computed color blocks from decoded PEN data + for (const penBlock of displayPattern.penStitches.colorBlocks) { + const thread = displayPattern.threads[penBlock.colorIndex]; + blocks.push({ + colorIndex: penBlock.colorIndex, + threadHex: thread?.hex || "#000000", + threadCatalogNumber: thread?.catalogNumber ?? null, + threadBrand: thread?.brand ?? null, + threadDescription: thread?.description ?? null, + threadChart: thread?.chart ?? null, + startStitch: penBlock.startStitchIndex, + endStitch: penBlock.endStitchIndex, + stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex, + }); + } + + return blocks; + }, [displayPattern]); + + // Determine current color block based on current stitch + const currentStitch = sewingProgress?.currentStitch || 0; + const currentBlockIndex = colorBlocks.findIndex( + (block) => + currentStitch >= block.startStitch && currentStitch < block.endStitch, + ); + + // Calculate time based on color blocks (matches Brother app calculation) + const { totalMinutes, elapsedMinutes } = useMemo(() => { + if (colorBlocks.length === 0) { + return { totalMinutes: 0, elapsedMinutes: 0 }; + } + const result = calculatePatternTime(colorBlocks, currentStitch); + return { + totalMinutes: result.totalMinutes, + elapsedMinutes: result.elapsedMinutes, + }; + }, [colorBlocks, currentStitch]); + + // Auto-scroll to current block + const currentBlockRef = useAutoScroll(currentBlockIndex); + + return ( + + +
+ +
+ Sewing Progress + {sewingProgress && ( + + {progressPercent.toFixed(1)}% complete + + )} +
+
+
+ + {/* Pattern Info */} + {patternInfo && ( + + )} + + {/* Progress Bar */} + {sewingProgress && ( + + )} + + {/* Color Blocks */} + + + {/* Action buttons */} + + +
+ ); +} diff --git a/src/components/ProgressMonitor/ProgressSection.tsx b/src/components/ProgressMonitor/ProgressSection.tsx new file mode 100644 index 0000000..4370e13 --- /dev/null +++ b/src/components/ProgressMonitor/ProgressSection.tsx @@ -0,0 +1,49 @@ +/** + * ProgressSection Component + * + * Displays the progress bar and current/total stitch information + */ + +import { Progress } from "@/components/ui/progress"; + +interface ProgressSectionProps { + currentStitch: number; + totalStitches: number; + elapsedMinutes: number; + totalMinutes: number; + progressPercent: number; +} + +export function ProgressSection({ + currentStitch, + totalStitches, + elapsedMinutes, + totalMinutes, + progressPercent, +}: ProgressSectionProps) { + return ( +
+ + +
+
+ + Current Stitch + + + {currentStitch.toLocaleString()} / {totalStitches.toLocaleString()} + +
+
+ Time + + {elapsedMinutes} / {totalMinutes} min + +
+
+
+ ); +} diff --git a/src/components/ProgressMonitor/ProgressStats.tsx b/src/components/ProgressMonitor/ProgressStats.tsx new file mode 100644 index 0000000..d65945c --- /dev/null +++ b/src/components/ProgressMonitor/ProgressStats.tsx @@ -0,0 +1,44 @@ +/** + * ProgressStats Component + * + * Displays three stat cards: total stitches, total time, and speed + */ + +interface ProgressStatsProps { + totalStitches: number; + totalMinutes: number; + speed: number; +} + +export function ProgressStats({ + totalStitches, + totalMinutes, + speed, +}: ProgressStatsProps) { + return ( +
+
+ + Total Stitches + + + {totalStitches.toLocaleString()} + +
+
+ + Total Time + + + {totalMinutes} min + +
+
+ Speed + + {speed} spm + +
+
+ ); +} diff --git a/src/components/ProgressMonitor/index.ts b/src/components/ProgressMonitor/index.ts new file mode 100644 index 0000000..6c59a59 --- /dev/null +++ b/src/components/ProgressMonitor/index.ts @@ -0,0 +1,5 @@ +/** + * ProgressMonitor component barrel export + */ + +export { ProgressMonitor } from "./ProgressMonitor"; diff --git a/src/components/ProgressMonitor/types.ts b/src/components/ProgressMonitor/types.ts new file mode 100644 index 0000000..103049e --- /dev/null +++ b/src/components/ProgressMonitor/types.ts @@ -0,0 +1,15 @@ +/** + * Shared types for ProgressMonitor components + */ + +export interface ColorBlock { + colorIndex: number; + threadHex: string; + startStitch: number; + endStitch: number; + stitchCount: number; + threadCatalogNumber: string | null; + threadBrand: string | null; + threadDescription: string | null; + threadChart: string | null; +} diff --git a/src/components/WorkflowStepper.tsx b/src/components/WorkflowStepper.tsx deleted file mode 100644 index 40c12fe..0000000 --- a/src/components/WorkflowStepper.tsx +++ /dev/null @@ -1,487 +0,0 @@ -import { useState, useRef } from "react"; -import { useClickOutside } from "@/hooks"; -import { useShallow } from "zustand/react/shallow"; -import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; -import { usePatternStore } from "../stores/usePatternStore"; -import { - CheckCircleIcon, - InformationCircleIcon, - ExclamationTriangleIcon, -} from "@heroicons/react/24/solid"; -import { MachineStatus } from "../types/machine"; - -interface Step { - id: number; - label: string; - description: string; -} - -const steps: Step[] = [ - { id: 1, label: "Connect", description: "Connect to machine" }, - { id: 2, label: "Home Machine", description: "Initialize hoop position" }, - { id: 3, label: "Load Pattern", description: "Choose PES file" }, - { id: 4, label: "Upload", description: "Upload to machine" }, - { id: 5, label: "Mask Trace", description: "Trace pattern area" }, - { id: 6, label: "Start Sewing", description: "Begin embroidery" }, - { id: 7, label: "Monitor", description: "Watch progress" }, - { id: 8, label: "Complete", description: "Finish and remove" }, -]; - -// Helper function to get guide content for a step -function getGuideContent(stepId: number, machineStatus: MachineStatus) { - // Return content based on step - switch (stepId) { - case 1: - return { - type: "info" as const, - title: "Step 1: Connect to Machine", - description: - "To get started, connect to your Brother embroidery machine via Bluetooth.", - items: [ - "Make sure your machine is powered on", - "Enable Bluetooth on your machine", - 'Click the "Connect to Machine" button below', - ], - }; - - case 2: - return { - type: "info" as const, - title: "Step 2: Home Machine", - description: - "The hoop needs to be removed and an initial homing procedure must be performed.", - items: [ - "Remove the embroidery hoop from the machine completely", - "Press the Accept button on the machine", - "Wait for the machine to complete its initialization (homing)", - "Once initialization is complete, reattach the hoop", - "The machine should now recognize the hoop correctly", - ], - }; - - case 3: - return { - type: "info" as const, - title: "Step 3: Load Your Pattern", - description: - "Choose a PES embroidery file from your computer to preview and upload.", - items: [ - 'Click "Choose PES File" in the Pattern File section', - "Select your embroidery design (.pes file)", - "Review the pattern preview on the right", - "You can drag the pattern to adjust its position", - ], - }; - - case 4: - return { - type: "info" as const, - title: "Step 4: Upload Pattern to Machine", - description: - "Send your pattern to the embroidery machine to prepare for sewing.", - items: [ - "Review the pattern preview to ensure it's positioned correctly", - "Check the pattern size matches your hoop", - 'Click "Upload to Machine" when ready', - "Wait for the upload to complete (this may take a minute)", - ], - }; - - case 5: - // Check machine status for substates - if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) { - return { - type: "warning" as const, - title: "Machine Action Required", - description: "The machine is ready to trace the pattern outline.", - items: [ - "Press the button on your machine to confirm and start the mask trace", - "Ensure the hoop is properly attached", - "Make sure the needle area is clear", - ], - }; - } - if (machineStatus === MachineStatus.MASK_TRACING) { - return { - type: "progress" as const, - title: "Mask Trace In Progress", - description: - "The machine is tracing the pattern boundary. Please wait...", - items: [ - "Watch the machine trace the outline", - "Verify the pattern fits within your hoop", - "Do not interrupt the machine", - ], - }; - } - return { - type: "info" as const, - title: "Step 5: Start Mask Trace", - description: - "The mask trace helps the machine understand the pattern boundaries.", - items: [ - 'Click "Start Mask Trace" button in the Sewing Progress section', - "The machine will trace the pattern outline", - "This ensures the hoop is positioned correctly", - ], - }; - - case 6: - return { - type: "success" as const, - title: "Step 6: Ready to Sew!", - description: "The machine is ready to begin embroidering your pattern.", - items: [ - "Verify your thread colors are correct", - "Ensure the fabric is properly hooped", - 'Click "Start Sewing" when ready', - ], - }; - - case 7: - // Check for substates - if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) { - return { - type: "warning" as const, - title: "Thread Change Required", - description: - "The machine needs a different thread color to continue.", - items: [ - "Check the color blocks section to see which thread is needed", - "Change to the correct thread color", - "Press the button on your machine to resume sewing", - ], - }; - } - if ( - machineStatus === MachineStatus.PAUSE || - machineStatus === MachineStatus.STOP || - machineStatus === MachineStatus.SEWING_INTERRUPTION - ) { - return { - type: "warning" as const, - title: "Sewing Paused", - description: "The embroidery has been paused or interrupted.", - items: [ - "Check if everything is okay with the machine", - 'Click "Resume Sewing" when ready to continue', - "The machine will pick up where it left off", - ], - }; - } - return { - type: "progress" as const, - title: "Step 7: Sewing In Progress", - description: - "Your embroidery is being stitched. Monitor the progress below.", - items: [ - "Watch the progress bar and current stitch count", - "The machine will pause when a color change is needed", - "Do not leave the machine unattended", - ], - }; - - case 8: - return { - type: "success" as const, - title: "Step 8: Embroidery Complete!", - description: "Your embroidery is finished. Great work!", - items: [ - "Remove the hoop from the machine", - "Press the Accept button on the machine", - "Carefully remove your finished embroidery", - "Trim any jump stitches or loose threads", - 'Click "Delete Pattern" to start a new project', - ], - }; - - default: - return null; - } -} - -function getCurrentStep( - machineStatus: MachineStatus, - isConnected: boolean, - hasPattern: boolean, - patternUploaded: boolean, -): number { - if (!isConnected) return 1; - - // Check if machine needs homing (Initial state) - if (machineStatus === MachineStatus.Initial) return 2; - - if (!hasPattern) return 3; - if (!patternUploaded) return 4; - - // After upload, determine step based on machine status - switch (machineStatus) { - case MachineStatus.IDLE: - case MachineStatus.MASK_TRACE_LOCK_WAIT: - case MachineStatus.MASK_TRACING: - return 5; - - case MachineStatus.MASK_TRACE_COMPLETE: - case MachineStatus.SEWING_WAIT: - return 6; - - case MachineStatus.SEWING: - case MachineStatus.COLOR_CHANGE_WAIT: - case MachineStatus.PAUSE: - case MachineStatus.STOP: - case MachineStatus.SEWING_INTERRUPTION: - return 7; - - case MachineStatus.SEWING_COMPLETE: - return 8; - - default: - return 5; - } -} - -export function WorkflowStepper() { - // Machine store - const { machineStatus, isConnected } = useMachineStore( - useShallow((state) => ({ - machineStatus: state.machineStatus, - isConnected: state.isConnected, - })), - ); - - // Pattern store - const { pesData } = usePatternStore( - useShallow((state) => ({ - pesData: state.pesData, - })), - ); - - // Derived state: pattern is uploaded if machine has pattern info - const patternUploaded = usePatternUploaded(); - const hasPattern = pesData !== null; - const currentStep = getCurrentStep( - machineStatus, - isConnected, - hasPattern, - patternUploaded, - ); - const [showPopover, setShowPopover] = useState(false); - const [popoverStep, setPopoverStep] = useState(null); - const popoverRef = useRef(null); - const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); - - // Close popover when clicking outside (exclude step circles) - useClickOutside(popoverRef, () => setShowPopover(false), { - enabled: showPopover, - excludeRefs: [stepRefs], - }); - - const handleStepClick = (stepId: number) => { - // Only allow clicking on current step or earlier completed steps - if (stepId <= currentStep) { - if (showPopover && popoverStep === stepId) { - setShowPopover(false); - setPopoverStep(null); - } else { - setPopoverStep(stepId); - setShowPopover(true); - } - } - }; - - return ( -
- {/* Progress bar background */} -
- - {/* Progress bar fill */} -
- - {/* Steps */} -
- {steps.map((step) => { - const isComplete = step.id < currentStep; - const isCurrent = step.id === currentStep; - const isUpcoming = step.id > currentStep; - - return ( -
- {/* Step circle */} -
{ - stepRefs.current[step.id] = el; - }} - onClick={() => handleStepClick(step.id)} - className={` - w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md - ${step.id <= currentStep ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"} - ${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""} - ${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""} - ${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""} - ${showPopover && popoverStep === step.id ? "ring-4 ring-white dark:ring-gray-800" : ""} - `} - aria-label={`${step.label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`} - role="button" - tabIndex={step.id <= currentStep ? 0 : -1} - > - {isComplete ? ( -
- - {/* Step label */} -
-
- {step.label} -
-
-
- ); - })} -
- - {/* Popover */} - {showPopover && popoverStep !== null && ( -
- {(() => { - const content = getGuideContent(popoverStep, machineStatus); - if (!content) return null; - - const colorClasses = { - info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500", - success: - "bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500", - warning: - "bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500", - error: - "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500", - progress: - "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500", - }; - - const iconColorClasses = { - info: "text-info-600 dark:text-info-400", - success: "text-success-600 dark:text-success-400", - warning: "text-warning-600 dark:text-warning-400", - error: "text-danger-600 dark:text-danger-400", - progress: "text-info-600 dark:text-info-400", - }; - - const textColorClasses = { - info: "text-info-900 dark:text-info-200", - success: "text-success-900 dark:text-success-200", - warning: "text-warning-900 dark:text-warning-200", - error: "text-danger-900 dark:text-danger-200", - progress: "text-info-900 dark:text-info-200", - }; - - const descColorClasses = { - info: "text-info-800 dark:text-info-300", - success: "text-success-800 dark:text-success-300", - warning: "text-warning-800 dark:text-warning-300", - error: "text-danger-800 dark:text-danger-300", - progress: "text-info-800 dark:text-info-300", - }; - - const listColorClasses = { - info: "text-blue-700 dark:text-blue-300", - success: "text-green-700 dark:text-green-300", - warning: "text-yellow-700 dark:text-yellow-300", - error: "text-red-700 dark:text-red-300", - progress: "text-cyan-700 dark:text-cyan-300", - }; - - const Icon = - content.type === "warning" - ? ExclamationTriangleIcon - : InformationCircleIcon; - - return ( -
-
- -
-

- {content.title} -

-

- {content.description} -

- {content.items && content.items.length > 0 && ( -
    - {content.items.map((item, index) => ( -
  • $1", - ), - }} - /> - ))} -
- )} -
-
-
- ); - })()} -
- )} -
- ); -} diff --git a/src/components/WorkflowStepper/StepCircle.tsx b/src/components/WorkflowStepper/StepCircle.tsx new file mode 100644 index 0000000..ba86a8d --- /dev/null +++ b/src/components/WorkflowStepper/StepCircle.tsx @@ -0,0 +1,54 @@ +/** + * StepCircle Component + * + * Renders a circular step indicator with number or checkmark icon + */ + +import { forwardRef } from "react"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; + +export interface StepCircleProps { + stepId: number; + label: string; + isComplete: boolean; + isCurrent: boolean; + isUpcoming: boolean; + showPopover: boolean; + onClick: () => void; +} + +export const StepCircle = forwardRef( + ( + { stepId, label, isComplete, isCurrent, isUpcoming, showPopover, onClick }, + ref, + ) => { + return ( +
+ {isComplete ? ( +
+ ); + }, +); + +StepCircle.displayName = "StepCircle"; diff --git a/src/components/WorkflowStepper/StepLabel.tsx b/src/components/WorkflowStepper/StepLabel.tsx new file mode 100644 index 0000000..617b676 --- /dev/null +++ b/src/components/WorkflowStepper/StepLabel.tsx @@ -0,0 +1,29 @@ +/** + * StepLabel Component + * + * Renders the text label below each step circle + */ + +export interface StepLabelProps { + label: string; + isCurrent: boolean; + isComplete: boolean; +} + +export function StepLabel({ label, isCurrent, isComplete }: StepLabelProps) { + return ( +
+
+ {label} +
+
+ ); +} diff --git a/src/components/WorkflowStepper/StepPopover.tsx b/src/components/WorkflowStepper/StepPopover.tsx new file mode 100644 index 0000000..d625fae --- /dev/null +++ b/src/components/WorkflowStepper/StepPopover.tsx @@ -0,0 +1,125 @@ +/** + * StepPopover Component + * + * Renders the guidance popover with dynamic content based on step and machine status + */ + +import { forwardRef } from "react"; +import { + InformationCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/solid"; +import { MachineStatus } from "../../types/machine"; +import { getGuideContent } from "../../utils/workflowGuideContent"; + +export interface StepPopoverProps { + stepId: number; + machineStatus: MachineStatus; +} + +export const StepPopover = forwardRef( + ({ stepId, machineStatus }, ref) => { + const content = getGuideContent(stepId, machineStatus); + if (!content) return null; + + const colorClasses = { + info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500", + success: + "bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500", + warning: + "bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500", + error: + "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500", + progress: + "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500", + }; + + const iconColorClasses = { + info: "text-info-600 dark:text-info-400", + success: "text-success-600 dark:text-success-400", + warning: "text-warning-600 dark:text-warning-400", + error: "text-danger-600 dark:text-danger-400", + progress: "text-info-600 dark:text-info-400", + }; + + const textColorClasses = { + info: "text-info-900 dark:text-info-200", + success: "text-success-900 dark:text-success-200", + warning: "text-warning-900 dark:text-warning-200", + error: "text-danger-900 dark:text-danger-200", + progress: "text-info-900 dark:text-info-200", + }; + + const descColorClasses = { + info: "text-info-800 dark:text-info-300", + success: "text-success-800 dark:text-success-300", + warning: "text-warning-800 dark:text-warning-300", + error: "text-danger-800 dark:text-danger-300", + progress: "text-info-800 dark:text-info-300", + }; + + const listColorClasses = { + info: "text-blue-700 dark:text-blue-300", + success: "text-green-700 dark:text-green-300", + warning: "text-yellow-700 dark:text-yellow-300", + error: "text-red-700 dark:text-red-300", + progress: "text-cyan-700 dark:text-cyan-300", + }; + + const Icon = + content.type === "warning" + ? ExclamationTriangleIcon + : InformationCircleIcon; + + return ( +
+
+
+ +
+

+ {content.title} +

+

+ {content.description} +

+ {content.items && content.items.length > 0 && ( +
    + {content.items.map((item, index) => { + // Parse **text** markdown syntax into React elements safely + const parts = item.split(/(\*\*.*?\*\*)/); + return ( +
  • + {parts.map((part, i) => { + if (part.startsWith("**") && part.endsWith("**")) { + return {part.slice(2, -2)}; + } + return part; + })} +
  • + ); + })} +
+ )} +
+
+
+
+ ); + }, +); + +StepPopover.displayName = "StepPopover"; diff --git a/src/components/WorkflowStepper/WorkflowStepper.tsx b/src/components/WorkflowStepper/WorkflowStepper.tsx new file mode 100644 index 0000000..b2aebf8 --- /dev/null +++ b/src/components/WorkflowStepper/WorkflowStepper.tsx @@ -0,0 +1,144 @@ +/** + * WorkflowStepper Component + * + * Displays the 8-step embroidery workflow with progress tracking and contextual guidance + */ + +import { useState, useRef } from "react"; +import { useClickOutside } from "@/hooks"; +import { useShallow } from "zustand/react/shallow"; +import { + useMachineStore, + usePatternUploaded, +} from "../../stores/useMachineStore"; +import { usePatternStore } from "../../stores/usePatternStore"; +import { WORKFLOW_STEPS } from "../../constants/workflowSteps"; +import { getCurrentStep } from "../../utils/workflowStepCalculation"; +import { StepCircle } from "./StepCircle"; +import { StepLabel } from "./StepLabel"; +import { StepPopover } from "./StepPopover"; + +export function WorkflowStepper() { + // Machine store + const { machineStatus, isConnected } = useMachineStore( + useShallow((state) => ({ + machineStatus: state.machineStatus, + isConnected: state.isConnected, + })), + ); + + // Pattern store + const { pesData } = usePatternStore( + useShallow((state) => ({ + pesData: state.pesData, + })), + ); + + // Derived state: pattern is uploaded if machine has pattern info + const patternUploaded = usePatternUploaded(); + const hasPattern = pesData !== null; + const currentStep = getCurrentStep( + machineStatus, + isConnected, + hasPattern, + patternUploaded, + ); + const [showPopover, setShowPopover] = useState(false); + const [popoverStep, setPopoverStep] = useState(null); + const popoverRef = useRef(null); + const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); + + // Close popover when clicking outside (exclude step circles) + useClickOutside(popoverRef, () => setShowPopover(false), { + enabled: showPopover, + excludeRefs: [stepRefs], + }); + + const handleStepClick = (stepId: number) => { + // Only allow clicking on current step or earlier completed steps + if (stepId <= currentStep) { + if (showPopover && popoverStep === stepId) { + setShowPopover(false); + setPopoverStep(null); + } else { + setPopoverStep(stepId); + setShowPopover(true); + } + } + }; + + return ( +
+ {/* Progress bar background */} +
+ + {/* Progress bar fill */} +
+ + {/* Steps */} +
+ {WORKFLOW_STEPS.map((step) => { + const isComplete = step.id < currentStep; + const isCurrent = step.id === currentStep; + const isUpcoming = step.id > currentStep; + + return ( +
+ { + stepRefs.current[step.id] = el; + }} + stepId={step.id} + label={step.label} + isComplete={isComplete} + isCurrent={isCurrent} + isUpcoming={isUpcoming} + showPopover={showPopover && popoverStep === step.id} + onClick={() => handleStepClick(step.id)} + /> + + +
+ ); + })} +
+ + {/* Popover */} + {showPopover && popoverStep !== null && ( + + )} +
+ ); +} diff --git a/src/components/WorkflowStepper/index.ts b/src/components/WorkflowStepper/index.ts new file mode 100644 index 0000000..e1b594e --- /dev/null +++ b/src/components/WorkflowStepper/index.ts @@ -0,0 +1,5 @@ +/** + * WorkflowStepper component barrel export + */ + +export { WorkflowStepper } from "./WorkflowStepper"; diff --git a/src/constants/workflowSteps.ts b/src/constants/workflowSteps.ts new file mode 100644 index 0000000..264ccea --- /dev/null +++ b/src/constants/workflowSteps.ts @@ -0,0 +1,20 @@ +/** + * Workflow step definitions for the embroidery process + */ + +export interface WorkflowStep { + readonly id: number; + readonly label: string; + readonly description: string; +} + +export const WORKFLOW_STEPS: readonly WorkflowStep[] = [ + { id: 1, label: "Connect", description: "Connect to machine" }, + { id: 2, label: "Home Machine", description: "Initialize hoop position" }, + { id: 3, label: "Load Pattern", description: "Choose PES file" }, + { id: 4, label: "Upload", description: "Upload to machine" }, + { id: 5, label: "Mask Trace", description: "Trace pattern area" }, + { id: 6, label: "Start Sewing", description: "Begin embroidery" }, + { id: 7, label: "Monitor", description: "Watch progress" }, + { id: 8, label: "Complete", description: "Finish and remove" }, +] as const; diff --git a/src/utils/displayFilename.ts b/src/utils/displayFilename.ts new file mode 100644 index 0000000..39cae3c --- /dev/null +++ b/src/utils/displayFilename.ts @@ -0,0 +1,22 @@ +/** + * getDisplayFilename Utility + * + * Determines which filename to display based on priority: + * 1. currentFileName (from pattern store) + * 2. localFileName (from file input) + * 3. resumeFileName (from cache) + * 4. Empty string + */ + +export function getDisplayFilename(options: { + currentFileName: string | null; + localFileName: string; + resumeFileName: string | null; +}): string { + return ( + options.currentFileName || + options.localFileName || + options.resumeFileName || + "" + ); +} diff --git a/src/utils/threadMetadata.ts b/src/utils/threadMetadata.ts new file mode 100644 index 0000000..0854573 --- /dev/null +++ b/src/utils/threadMetadata.ts @@ -0,0 +1,82 @@ +/** + * Format thread metadata for display. + * + * Combines brand, catalog number, chart, and description into a readable string + * using the following rules: + * + * - The primary part consists of the brand and catalog number: + * - The brand (if present) appears first. + * - The catalog number (if present) is prefixed with `#` and appended after + * the brand, separated by a single space (e.g. `"DMC #310"`). + * - The secondary part consists of the chart and description: + * - The chart is omitted if it is `null`/empty or exactly equal to + * `threadCatalogNumber`. + * - The chart (when shown) and the description are joined with a single + * space (e.g. `"Anchor 24-colour Black"`). + * - The primary and secondary parts are joined with `" • "` (space, bullet, + * space). If either part is empty, only the non-empty part is returned. + * + * Examples: + * + * - Brand and catalog only: + * - Input: + * - `threadBrand: "DMC"` + * - `threadCatalogNumber: "310"` + * - `threadChart: null` + * - `threadDescription: null` + * - Output: `"DMC #310"` + * + * - Brand, catalog, and description: + * - Input: + * - `threadBrand: "DMC"` + * - `threadCatalogNumber: "310"` + * - `threadChart: null` + * - `threadDescription: "Black"` + * - Output: `"DMC #310 • Black"` + * + * - Brand, catalog, chart (different from catalog), and description: + * - Input: + * - `threadBrand: "Anchor"` + * - `threadCatalogNumber: "403"` + * - `threadChart: "24-colour"` + * - `threadDescription: "Black"` + * - Output: `"Anchor #403 • 24-colour Black"` + * + * - Chart equal to catalog number (chart omitted): + * - Input: + * - `threadBrand: "DMC"` + * - `threadCatalogNumber: "310"` + * - `threadChart: "310"` + * - `threadDescription: "Black"` + * - Output: `"DMC #310 • Black"` + */ + +interface ThreadMetadata { + threadBrand: string | null; + threadCatalogNumber: string | null; + threadChart: string | null; + threadDescription: string | null; +} + +export function formatThreadMetadata(thread: ThreadMetadata): string { + // Primary metadata: brand and catalog number + const primaryMetadata = [ + thread.threadBrand, + thread.threadCatalogNumber ? `#${thread.threadCatalogNumber}` : null, + ] + .filter(Boolean) + .join(" "); + + // Secondary metadata: chart and description + // Only show chart if it's different from catalogNumber + const secondaryMetadata = [ + thread.threadChart && thread.threadChart !== thread.threadCatalogNumber + ? thread.threadChart + : null, + thread.threadDescription, + ] + .filter(Boolean) + .join(" "); + + return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • "); +} diff --git a/src/utils/workflowGuideContent.ts b/src/utils/workflowGuideContent.ts new file mode 100644 index 0000000..e0d1b70 --- /dev/null +++ b/src/utils/workflowGuideContent.ts @@ -0,0 +1,195 @@ +/** + * Workflow step guide content + * + * Provides contextual guidance for each workflow step based on machine state + */ + +import { MachineStatus } from "../types/machine"; + +export interface GuideContent { + type: "info" | "warning" | "success" | "error" | "progress"; + title: string; + description: string; + items: string[]; +} + +/** + * Get guide content for a specific workflow step + * + * @param stepId - The workflow step ID (1-8) + * @param machineStatus - Current machine status for dynamic content + * @returns Guide content with type, title, description, and items + */ +export function getGuideContent( + stepId: number, + machineStatus: MachineStatus, +): GuideContent | null { + switch (stepId) { + case 1: + return { + type: "info", + title: "Step 1: Connect to Machine", + description: + "To get started, connect to your Brother embroidery machine via Bluetooth.", + items: [ + "Make sure your machine is powered on", + "Enable Bluetooth on your machine", + 'Click the "Connect to Machine" button below', + ], + }; + + case 2: + return { + type: "info", + title: "Step 2: Home Machine", + description: + "The hoop needs to be removed and an initial homing procedure must be performed.", + items: [ + "Remove the embroidery hoop from the machine completely", + "Press the Accept button on the machine", + "Wait for the machine to complete its initialization (homing)", + "Once initialization is complete, reattach the hoop", + "The machine should now recognize the hoop correctly", + ], + }; + + case 3: + return { + type: "info", + title: "Step 3: Load Your Pattern", + description: + "Choose a PES embroidery file from your computer to preview and upload.", + items: [ + 'Click "Choose PES File" in the Pattern File section', + "Select your embroidery design (.pes file)", + "Review the pattern preview on the right", + "You can drag the pattern to adjust its position", + ], + }; + + case 4: + return { + type: "info", + title: "Step 4: Upload Pattern to Machine", + description: + "Send your pattern to the embroidery machine to prepare for sewing.", + items: [ + "Review the pattern preview to ensure it's positioned correctly", + "Check the pattern size matches your hoop", + 'Click "Upload to Machine" when ready', + "Wait for the upload to complete (this may take a minute)", + ], + }; + + case 5: + // Check machine status for substates + if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) { + return { + type: "warning", + title: "Machine Action Required", + description: "The machine is ready to trace the pattern outline.", + items: [ + "Press the button on your machine to confirm and start the mask trace", + "Ensure the hoop is properly attached", + "Make sure the needle area is clear", + ], + }; + } + if (machineStatus === MachineStatus.MASK_TRACING) { + return { + type: "progress", + title: "Mask Trace In Progress", + description: + "The machine is tracing the pattern boundary. Please wait...", + items: [ + "Watch the machine trace the outline", + "Verify the pattern fits within your hoop", + "Do not interrupt the machine", + ], + }; + } + return { + type: "info", + title: "Step 5: Start Mask Trace", + description: + "The mask trace helps the machine understand the pattern boundaries.", + items: [ + 'Click "Start Mask Trace" button in the Sewing Progress section', + "The machine will trace the pattern outline", + "This ensures the hoop is positioned correctly", + ], + }; + + case 6: + return { + type: "success", + title: "Step 6: Ready to Sew!", + description: "The machine is ready to begin embroidering your pattern.", + items: [ + "Verify your thread colors are correct", + "Ensure the fabric is properly hooped", + 'Click "Start Sewing" when ready', + ], + }; + + case 7: + // Check for substates + if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) { + return { + type: "warning", + title: "Thread Change Required", + description: + "The machine needs a different thread color to continue.", + items: [ + "Check the color blocks section to see which thread is needed", + "Change to the correct thread color", + "Press the button on your machine to resume sewing", + ], + }; + } + if ( + machineStatus === MachineStatus.PAUSE || + machineStatus === MachineStatus.STOP || + machineStatus === MachineStatus.SEWING_INTERRUPTION + ) { + return { + type: "warning", + title: "Sewing Paused", + description: "The embroidery has been paused or interrupted.", + items: [ + "Check if everything is okay with the machine", + 'Click "Resume Sewing" when ready to continue', + "The machine will pick up where it left off", + ], + }; + } + return { + type: "progress", + title: "Step 7: Sewing In Progress", + description: + "Your embroidery is being stitched. Monitor the progress below.", + items: [ + "Watch the progress bar and current stitch count", + "The machine will pause when a color change is needed", + "Do not leave the machine unattended", + ], + }; + + case 8: + return { + type: "success", + title: "Step 8: Embroidery Complete!", + description: "Your embroidery is finished. Great work!", + items: [ + "Remove the hoop from the machine", + "Press the Accept button on the machine", + "Carefully remove your finished embroidery", + "Trim any jump stitches or loose threads", + 'Click "Delete Pattern" to start a new project', + ], + }; + + default: + return null; + } +} diff --git a/src/utils/workflowStepCalculation.ts b/src/utils/workflowStepCalculation.ts new file mode 100644 index 0000000..42e9c6c --- /dev/null +++ b/src/utils/workflowStepCalculation.ts @@ -0,0 +1,56 @@ +/** + * Workflow step calculation utilities + * + * Determines the current workflow step based on machine state and pattern status + */ + +import { MachineStatus } from "../types/machine"; + +/** + * Calculate the current workflow step based on machine state + * + * @param machineStatus - Current machine status + * @param isConnected - Whether machine is connected + * @param hasPattern - Whether a pattern is loaded + * @param patternUploaded - Whether pattern has been uploaded to machine + * @returns Current step number (1-8) + */ +export function getCurrentStep( + machineStatus: MachineStatus, + isConnected: boolean, + hasPattern: boolean, + patternUploaded: boolean, +): number { + if (!isConnected) return 1; + + // Check if machine needs homing (Initial state) + if (machineStatus === MachineStatus.Initial) return 2; + + if (!hasPattern) return 3; + if (!patternUploaded) return 4; + + // After upload, determine step based on machine status + switch (machineStatus) { + case MachineStatus.IDLE: + case MachineStatus.MASK_TRACE_LOCK_WAIT: + case MachineStatus.MASK_TRACING: + return 5; + + case MachineStatus.MASK_TRACE_COMPLETE: + case MachineStatus.SEWING_WAIT: + return 6; + + case MachineStatus.SEWING: + case MachineStatus.COLOR_CHANGE_WAIT: + case MachineStatus.PAUSE: + case MachineStatus.STOP: + case MachineStatus.SEWING_INTERRUPTION: + return 7; + + case MachineStatus.SEWING_COMPLETE: + return 8; + + default: + return 5; + } +}