Merge pull request #56 from jhbruhn/refactor/extract-sub-components

refactor: Extract sub-components from large components
This commit is contained in:
Jan-Henrik Bruhn 2025-12-27 16:43:19 +01:00 committed by GitHub
commit 89bc55b822
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1823 additions and 1265 deletions

View file

@ -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<PesPatternData | null>(null);
const [fileName, setFileName] = useState<string>("");
const [fileService] = useState<IFileService>(() => 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 (
<Card className={cn("p-0 gap-0 border-l-4", borderColor)}>
<CardContent className="p-4 rounded-lg">
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon
className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)}
/>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Pattern File
</h3>
{pesData && displayFileName ? (
<p
className="text-xs text-gray-600 dark:text-gray-400 truncate"
title={displayFileName}
>
{displayFileName}
</p>
) : (
<p className="text-xs text-gray-600 dark:text-gray-400">
No pattern loaded
</p>
)}
</div>
</div>
{resumeAvailable && resumeFileName && (
<div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
<p className="text-xs text-success-800 dark:text-success-200">
<strong>Cached:</strong> "{resumeFileName}"
</p>
</div>
)}
{isLoading && <PatternInfoSkeleton />}
{!isLoading && pesData && (
<div className="mb-3">
<PatternInfo pesData={pesData} showThreadBlocks />
</div>
)}
<div className="flex gap-2 mb-3">
<input
type="file"
accept=".pes"
onChange={handleFileChange}
id="file-input"
className="hidden"
disabled={
isLoading ||
patternUploaded ||
isUploading ||
(uploadProgress > 0 && !patternUploaded)
}
/>
<Button
asChild={
!fileService.hasNativeDialogs() &&
!(
isLoading ||
patternUploaded ||
isUploading ||
(uploadProgress > 0 && !patternUploaded)
)
}
onClick={
fileService.hasNativeDialogs()
? () => handleFileChange()
: undefined
}
disabled={
isLoading ||
patternUploaded ||
isUploading ||
(uploadProgress > 0 && !patternUploaded)
}
variant="outline"
className="flex-[2]"
>
{fileService.hasNativeDialogs() ? (
<>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</>
) : (
<label
htmlFor="file-input"
className="flex items-center gap-2 cursor-pointer"
>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</label>
)}
</Button>
{pesData &&
canUploadPattern(machineStatus) &&
!patternUploaded &&
uploadProgress < 100 && (
<Button
onClick={handleUpload}
disabled={!isConnected || isUploading || !boundsCheck.fits}
className="flex-1"
aria-label={
isUploading
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
: boundsCheck.error || "Upload pattern to machine"
}
>
{isUploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{uploadProgress > 0
? uploadProgress.toFixed(0) + "%"
: "Uploading"}
</>
) : (
<>
<ArrowUpTrayIcon className="w-3.5 h-3.5" />
Upload
</>
)}
</Button>
)}
</div>
{/* Pyodide initialization progress indicator - shown when initializing or waiting */}
{!pyodideReady && pyodideProgress > 0 && (
<div className="mb-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{isLoading && !pyodideReady
? "Please wait - initializing Python environment..."
: pyodideLoadingStep || "Initializing Python environment..."}
</span>
<span className="text-xs font-bold text-primary-600 dark:text-primary-400">
{pyodideProgress.toFixed(0)}%
</span>
</div>
<Progress value={pyodideProgress} className="h-2.5" />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
{isLoading && !pyodideReady
? "File dialog will open automatically when ready"
: "This only happens once on first use"}
</p>
</div>
)}
{/* Error/warning messages with smooth transition - placed after buttons */}
<div
className="transition-all duration-200 ease-in-out overflow-hidden"
style={{
maxHeight:
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
? "200px"
: "0px",
marginTop:
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
? "12px"
: "0px",
}}
>
{pesData && !canUploadPattern(machineStatus) && (
<Alert className="bg-warning-100 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<AlertDescription className="text-warning-800 dark:text-warning-200 text-sm">
Cannot upload while {getMachineStateCategory(machineStatus)}
</AlertDescription>
</Alert>
)}
{pesData && boundsCheck.error && (
<Alert variant="destructive">
<AlertDescription>
<strong>Pattern too large:</strong> {boundsCheck.error}
</AlertDescription>
</Alert>
)}
</div>
{isUploading && uploadProgress < 100 && (
<div className="mt-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Uploading
</span>
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
{uploadProgress > 0
? uploadProgress.toFixed(1) + "%"
: "Starting..."}
</span>
</div>
<Progress
value={uploadProgress}
className="h-2.5 [&>div]:bg-gradient-to-r [&>div]:from-secondary-500 [&>div]:via-secondary-600 [&>div]:to-secondary-700 dark:[&>div]:from-secondary-600 dark:[&>div]:via-secondary-700 dark:[&>div]:to-secondary-800"
/>
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -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 (
<div
className="transition-all duration-200 ease-in-out overflow-hidden"
style={{
maxHeight: hasError ? "200px" : "0px",
marginTop: hasError ? "12px" : "0px",
}}
>
{pesData && !canUploadPattern(machineStatus) && (
<Alert className="bg-warning-100 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<AlertDescription className="text-warning-800 dark:text-warning-200 text-sm">
Cannot upload while {getMachineStateCategory(machineStatus)}
</AlertDescription>
</Alert>
)}
{pesData && boundsError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Pattern too large:</strong> {boundsError}
</AlertDescription>
</Alert>
)}
</div>
);
}

View file

@ -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<HTMLInputElement>) => Promise<void>;
displayFileName: string;
patternUploaded: boolean;
}
export function FileSelector({
fileService,
isLoading,
isDisabled,
onFileChange,
patternUploaded,
}: FileSelectorProps) {
const hasNativeDialogs = fileService.hasNativeDialogs();
return (
<>
<input
type="file"
accept=".pes"
onChange={onFileChange}
id="file-input"
className="hidden"
disabled={isDisabled}
/>
<Button
asChild={!hasNativeDialogs && !isDisabled}
onClick={hasNativeDialogs ? () => onFileChange() : undefined}
disabled={isDisabled}
variant="outline"
className="flex-[2]"
>
{hasNativeDialogs ? (
<>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</>
) : (
<label
htmlFor="file-input"
className="flex items-center gap-2 cursor-pointer"
>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</label>
)}
</Button>
</>
);
}

View file

@ -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<PesPatternData | null>(null);
const [fileName, setFileName] = useState<string>("");
const [fileService] = useState<IFileService>(() => 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 (
<Card className={cn("p-0 gap-0 border-l-4", borderColor)}>
<CardContent className="p-4 rounded-lg">
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon
className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)}
/>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Pattern File
</h3>
{pesData && displayFileName ? (
<p
className="text-xs text-gray-600 dark:text-gray-400 truncate"
title={displayFileName}
>
{displayFileName}
</p>
) : (
<p className="text-xs text-gray-600 dark:text-gray-400">
No pattern loaded
</p>
)}
</div>
</div>
{resumeAvailable && resumeFileName && (
<div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
<p className="text-xs text-success-800 dark:text-success-200">
<strong>Cached:</strong> "{resumeFileName}"
</p>
</div>
)}
{isLoading && <PatternInfoSkeleton />}
{!isLoading && pesData && (
<div className="mb-3">
<PatternInfo pesData={pesData} showThreadBlocks />
</div>
)}
<div className="flex gap-2 mb-3">
<FileSelector
fileService={fileService}
isLoading={isLoading}
isDisabled={isSelectorDisabled}
onFileChange={handleFileChange}
displayFileName={displayFileName}
patternUploaded={patternUploaded}
/>
<UploadButton
pesData={pesData}
machineStatus={machineStatus}
isConnected={isConnected}
isUploading={isUploading}
uploadProgress={uploadProgress}
boundsFits={boundsCheck.fits}
boundsError={boundsCheck.error}
onUpload={handleUpload}
patternUploaded={patternUploaded}
/>
</div>
<PyodideProgress
pyodideReady={pyodideReady}
pyodideProgress={pyodideProgress}
pyodideLoadingStep={pyodideLoadingStep}
isFileLoading={isLoading}
/>
<BoundsValidator
pesData={pesData}
machineStatus={machineStatus}
boundsError={boundsCheck.error}
/>
<UploadProgress
isUploading={isUploading}
uploadProgress={uploadProgress}
/>
</CardContent>
</Card>
);
}

View file

@ -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 (
<div className="mb-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{isFileLoading
? "Please wait - initializing Python environment..."
: pyodideLoadingStep || "Initializing Python environment..."}
</span>
<span className="text-xs font-bold text-primary-600 dark:text-primary-400">
{pyodideProgress.toFixed(0)}%
</span>
</div>
<Progress value={pyodideProgress} className="h-2.5" />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
{isFileLoading
? "File dialog will open automatically when ready"
: "This only happens once on first use"}
</p>
</div>
);
}

View file

@ -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<void>;
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 (
<Button
onClick={onUpload}
disabled={!isConnected || isUploading || !boundsFits}
className="flex-1"
aria-label={
isUploading
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
: boundsError || "Upload pattern to machine"
}
>
{isUploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{uploadProgress > 0 ? uploadProgress.toFixed(0) + "%" : "Uploading"}
</>
) : (
<>
<ArrowUpTrayIcon className="w-3.5 h-3.5" />
Upload
</>
)}
</Button>
);
}

View file

@ -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 (
<div className="mt-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Uploading
</span>
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
{uploadProgress > 0 ? uploadProgress.toFixed(1) + "%" : "Starting..."}
</span>
</div>
<Progress
value={uploadProgress}
className="h-2.5 [&>div]:bg-gradient-to-r [&>div]:from-secondary-500 [&>div]:via-secondary-600 [&>div]:to-secondary-700 dark:[&>div]:from-secondary-600 dark:[&>div]:via-secondary-700 dark:[&>div]:to-secondary-800"
/>
</div>
);
}

View file

@ -0,0 +1,5 @@
/**
* FileUpload component barrel export
*/
export { FileUpload } from "./FileUpload";

View file

@ -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<HTMLDivElement>(currentBlockIndex);
return (
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Sewing Progress</CardTitle>
{sewingProgress && (
<CardDescription className="text-xs">
{progressPercent.toFixed(1)}% complete
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
{/* Pattern Info */}
{patternInfo && (
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{totalStitches.toLocaleString()}
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Total Time
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{totalMinutes} min
</span>
</div>
<div className="bg-gray-200 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">
<Progress
value={progressPercent}
className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
/>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Current Stitch
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{sewingProgress.currentStitch.toLocaleString()} /{" "}
{totalStitches.toLocaleString()}
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Time
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{elapsedMinutes} / {totalMinutes} min
</span>
</div>
</div>
</div>
)}
{/* Color Blocks */}
{colorBlocks.length > 0 && (
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
Color Blocks
</h4>
<ScrollArea className="lg:flex-1 lg:h-0">
<div className="flex flex-col gap-2 pr-4">
{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}
ref={isCurrent ? currentBlockRef : null}
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted
? "border-success-600 bg-success-50 dark:bg-success-900/20"
: isCurrent
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
: "border-gray-200 dark:border-gray-600 bg-gray-100 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,
}}
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}
{(block.threadBrand ||
block.threadChart ||
block.threadDescription ||
block.threadCatalogNumber) && (
<span className="font-normal text-gray-600 dark:text-gray-400">
{" "}
(
{(() => {
// 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(" • ");
})()}
)
</span>
)}
</div>
<div className="text-xs 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-success-600 flex-shrink-0"
aria-label="Completed"
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-gray-600 dark:text-gray-400 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 && (
<Progress
value={blockProgress}
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
aria-label={`${Math.round(blockProgress)}% complete`}
/>
)}
</div>
);
})}
</div>
</ScrollArea>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2 flex-shrink-0">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<Button
onClick={resumeSewing}
disabled={isDeleting}
className="flex-1"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</Button>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<Button
onClick={startSewing}
disabled={isDeleting}
className="flex-[2]"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</Button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<Button
onClick={startMaskTrace}
disabled={isDeleting}
variant="outline"
className="flex-1"
aria-label={
isMaskTraceComplete
? "Start mask trace again"
: "Start mask trace"
}
>
<ArrowPathIcon className="w-3.5 h-3.5" />
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -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<HTMLDivElement, ColorBlockItemProps>(
({ 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 (
<div
key={index}
ref={isCurrent ? ref : null}
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted
? "border-success-600 bg-success-50 dark:bg-success-900/20"
: isCurrent
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
: "border-gray-200 dark:border-gray-600 bg-gray-100 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,
}}
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}
{hasMetadata && (
<span className="font-normal text-gray-600 dark:text-gray-400">
{" "}
({formatThreadMetadata(block)})
</span>
)}
</div>
<div className="text-xs 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-success-600 flex-shrink-0"
aria-label="Completed"
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-gray-600 dark:text-gray-400 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 && (
<Progress
value={blockProgress}
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
aria-label={`${Math.round(blockProgress)}% complete`}
/>
)}
</div>
);
},
);
ColorBlockItem.displayName = "ColorBlockItem";

View file

@ -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<HTMLDivElement | null>;
}
export function ColorBlockList({
colorBlocks,
currentStitch,
currentBlockIndex,
currentBlockRef,
}: ColorBlockListProps) {
if (colorBlocks.length === 0) return null;
return (
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
Color Blocks
</h4>
<ScrollArea className="lg:flex-1 lg:h-0">
<div className="flex flex-col gap-2 pr-4">
{colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch;
const isCurrent = index === currentBlockIndex;
return (
<ColorBlockItem
key={index}
ref={isCurrent ? currentBlockRef : null}
block={block}
index={index}
currentStitch={currentStitch}
isCurrent={isCurrent}
isCompleted={isCompleted}
/>
);
})}
</div>
</ScrollArea>
</div>
);
}

View file

@ -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 (
<div className="flex gap-2 flex-shrink-0">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<Button
onClick={onResumeSewing}
disabled={isDeleting}
className="flex-1"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</Button>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<Button
onClick={onStartSewing}
disabled={isDeleting}
className="flex-[2]"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</Button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<Button
onClick={onStartMaskTrace}
disabled={isDeleting}
variant="outline"
className="flex-1"
aria-label={
isMaskTraceComplete ? "Start mask trace again" : "Start mask trace"
}
>
<ArrowPathIcon className="w-3.5 h-3.5" />
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
</Button>
)}
</div>
);
}

View file

@ -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<HTMLDivElement>(currentBlockIndex);
return (
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Sewing Progress</CardTitle>
{sewingProgress && (
<CardDescription className="text-xs">
{progressPercent.toFixed(1)}% complete
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
{/* Pattern Info */}
{patternInfo && (
<ProgressStats
totalStitches={totalStitches}
totalMinutes={totalMinutes}
speed={patternInfo.speed}
/>
)}
{/* Progress Bar */}
{sewingProgress && (
<ProgressSection
currentStitch={sewingProgress.currentStitch}
totalStitches={totalStitches}
elapsedMinutes={elapsedMinutes}
totalMinutes={totalMinutes}
progressPercent={progressPercent}
/>
)}
{/* Color Blocks */}
<ColorBlockList
colorBlocks={colorBlocks}
currentStitch={currentStitch}
currentBlockIndex={currentBlockIndex}
currentBlockRef={currentBlockRef}
/>
{/* Action buttons */}
<ProgressActions
machineStatus={machineStatus}
isDeleting={isDeleting}
isMaskTraceComplete={isMaskTraceComplete}
onResumeSewing={resumeSewing}
onStartSewing={startSewing}
onStartMaskTrace={startMaskTrace}
/>
</CardContent>
</Card>
);
}

View file

@ -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 (
<div className="mb-3">
<Progress
value={progressPercent}
className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
/>
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Current Stitch
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{currentStitch.toLocaleString()} / {totalStitches.toLocaleString()}
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Time</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{elapsedMinutes} / {totalMinutes} min
</span>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{totalStitches.toLocaleString()}
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Total Time
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{totalMinutes} min
</span>
</div>
<div className="bg-gray-200 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">
{speed} spm
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,5 @@
/**
* ProgressMonitor component barrel export
*/
export { ProgressMonitor } from "./ProgressMonitor";

View file

@ -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;
}

View file

@ -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<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
// Close popover when clicking outside (exclude step circles)
useClickOutside<HTMLDivElement>(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 (
<div
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
role="navigation"
aria-label="Workflow progress"
>
{/* Progress bar background */}
<div
className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
style={{ left: "16px", right: "16px" }}
/>
{/* Progress bar fill */}
<div
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
style={{
left: "16px",
width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`,
}}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={steps.length}
aria-label={`Step ${currentStep} of ${steps.length}`}
/>
{/* Steps */}
<div className="flex justify-between relative">
{steps.map((step) => {
const isComplete = step.id < currentStep;
const isCurrent = step.id === currentStep;
const isUpcoming = step.id > currentStep;
return (
<div
key={step.id}
className="flex flex-col items-center"
style={{ flex: 1 }}
role="listitem"
aria-current={isCurrent ? "step" : undefined}
>
{/* Step circle */}
<div
ref={(el) => {
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 ? (
<CheckCircleIcon
className="w-5 h-5 lg:w-6 lg:h-6"
aria-hidden="true"
/>
) : (
step.id
)}
</div>
{/* Step label */}
<div className="mt-1 lg:mt-2 text-center">
<div
className={`text-xs font-semibold leading-tight ${
isCurrent
? "text-white"
: isComplete
? "text-success-200 dark:text-success-300"
: "text-primary-300/70 dark:text-primary-400/70"
}`}
>
{step.label}
</div>
</div>
</div>
);
})}
</div>
{/* Popover */}
{showPopover && popoverStep !== null && (
<div
ref={popoverRef}
className="absolute top-full mt-4 left-1/2 transform -translate-x-1/2 w-full max-w-xl z-50 animate-fadeIn"
role="dialog"
aria-label="Step guidance"
>
{(() => {
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 (
<div
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3">
<Icon
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1">
<h3
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title}
</h3>
<p
className={`text-sm ${descColorClasses[content.type]} mb-3`}
>
{content.description}
</p>
{content.items && content.items.length > 0 && (
<ul
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => (
<li
key={index}
className="pl-2"
dangerouslySetInnerHTML={{
__html: item.replace(
/\*\*(.*?)\*\*/g,
"<strong>$1</strong>",
),
}}
/>
))}
</ul>
)}
</div>
</div>
</div>
);
})()}
</div>
)}
</div>
);
}

View file

@ -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<HTMLDivElement, StepCircleProps>(
(
{ stepId, label, isComplete, isCurrent, isUpcoming, showPopover, onClick },
ref,
) => {
return (
<div
ref={ref}
onClick={onClick}
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
${isComplete || isCurrent ? "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 ? "ring-4 ring-white dark:ring-gray-800" : ""}
`}
aria-label={`${label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`}
role="button"
tabIndex={isComplete || isCurrent ? 0 : -1}
>
{isComplete ? (
<CheckCircleIcon
className="w-5 h-5 lg:w-6 lg:h-6"
aria-hidden="true"
/>
) : (
stepId
)}
</div>
);
},
);
StepCircle.displayName = "StepCircle";

View file

@ -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 (
<div className="mt-1 lg:mt-2 text-center">
<div
className={`text-xs font-semibold leading-tight ${
isCurrent
? "text-white"
: isComplete
? "text-success-200 dark:text-success-300"
: "text-primary-300/70 dark:text-primary-400/70"
}`}
>
{label}
</div>
</div>
);
}

View file

@ -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<HTMLDivElement, StepPopoverProps>(
({ 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 (
<div
ref={ref}
className="absolute top-full mt-4 left-1/2 transform -translate-x-1/2 w-full max-w-xl z-50 animate-fadeIn"
role="dialog"
aria-label="Step guidance"
>
<div
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3">
<Icon
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1">
<h3
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title}
</h3>
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
{content.description}
</p>
{content.items && content.items.length > 0 && (
<ul
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => {
// Parse **text** markdown syntax into React elements safely
const parts = item.split(/(\*\*.*?\*\*)/);
return (
<li key={index} className="pl-2">
{parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={i}>{part.slice(2, -2)}</strong>;
}
return part;
})}
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
</div>
);
},
);
StepPopover.displayName = "StepPopover";

View file

@ -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<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
// Close popover when clicking outside (exclude step circles)
useClickOutside<HTMLDivElement>(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 (
<div
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
role="navigation"
aria-label="Workflow progress"
>
{/* Progress bar background */}
<div
className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
style={{ left: "16px", right: "16px" }}
/>
{/* Progress bar fill */}
<div
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
style={{
left: "16px",
width: `calc(${((currentStep - 1) / (WORKFLOW_STEPS.length - 1)) * 100}% - 16px)`,
}}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={WORKFLOW_STEPS.length}
aria-label={`Step ${currentStep} of ${WORKFLOW_STEPS.length}`}
/>
{/* Steps */}
<div className="flex justify-between relative">
{WORKFLOW_STEPS.map((step) => {
const isComplete = step.id < currentStep;
const isCurrent = step.id === currentStep;
const isUpcoming = step.id > currentStep;
return (
<div
key={step.id}
className="flex flex-col items-center"
style={{ flex: 1 }}
role="listitem"
aria-current={isCurrent ? "step" : undefined}
>
<StepCircle
ref={(el: HTMLDivElement | null) => {
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)}
/>
<StepLabel
label={step.label}
isCurrent={isCurrent}
isComplete={isComplete}
/>
</div>
);
})}
</div>
{/* Popover */}
{showPopover && popoverStep !== null && (
<StepPopover
ref={popoverRef}
stepId={popoverStep}
machineStatus={machineStatus}
/>
)}
</div>
);
}

View file

@ -0,0 +1,5 @@
/**
* WorkflowStepper component barrel export
*/
export { WorkflowStepper } from "./WorkflowStepper";

View file

@ -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;

View file

@ -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 ||
""
);
}

View file

@ -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(" • ");
}

View file

@ -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;
}
}

View file

@ -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;
}
}