mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
Merge pull request #56 from jhbruhn/refactor/extract-sub-components
refactor: Extract sub-components from large components
This commit is contained in:
commit
89bc55b822
28 changed files with 1823 additions and 1265 deletions
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
53
src/components/FileUpload/BoundsValidator.tsx
Normal file
53
src/components/FileUpload/BoundsValidator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/FileUpload/FileSelector.tsx
Normal file
92
src/components/FileUpload/FileSelector.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
src/components/FileUpload/FileUpload.tsx
Normal file
259
src/components/FileUpload/FileUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/FileUpload/PyodideProgress.tsx
Normal file
44
src/components/FileUpload/PyodideProgress.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/FileUpload/UploadButton.tsx
Normal file
69
src/components/FileUpload/UploadButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/FileUpload/UploadProgress.tsx
Normal file
36
src/components/FileUpload/UploadProgress.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/FileUpload/index.ts
Normal file
5
src/components/FileUpload/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* FileUpload component barrel export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { FileUpload } from "./FileUpload";
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
115
src/components/ProgressMonitor/ColorBlockItem.tsx
Normal file
115
src/components/ProgressMonitor/ColorBlockItem.tsx
Normal 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";
|
||||||
53
src/components/ProgressMonitor/ColorBlockList.tsx
Normal file
53
src/components/ProgressMonitor/ColorBlockList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/ProgressMonitor/ProgressActions.tsx
Normal file
78
src/components/ProgressMonitor/ProgressActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/components/ProgressMonitor/ProgressMonitor.tsx
Normal file
174
src/components/ProgressMonitor/ProgressMonitor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/ProgressMonitor/ProgressSection.tsx
Normal file
49
src/components/ProgressMonitor/ProgressSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/ProgressMonitor/ProgressStats.tsx
Normal file
44
src/components/ProgressMonitor/ProgressStats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/ProgressMonitor/index.ts
Normal file
5
src/components/ProgressMonitor/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* ProgressMonitor component barrel export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ProgressMonitor } from "./ProgressMonitor";
|
||||||
15
src/components/ProgressMonitor/types.ts
Normal file
15
src/components/ProgressMonitor/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
54
src/components/WorkflowStepper/StepCircle.tsx
Normal file
54
src/components/WorkflowStepper/StepCircle.tsx
Normal 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";
|
||||||
29
src/components/WorkflowStepper/StepLabel.tsx
Normal file
29
src/components/WorkflowStepper/StepLabel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/components/WorkflowStepper/StepPopover.tsx
Normal file
125
src/components/WorkflowStepper/StepPopover.tsx
Normal 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";
|
||||||
144
src/components/WorkflowStepper/WorkflowStepper.tsx
Normal file
144
src/components/WorkflowStepper/WorkflowStepper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/WorkflowStepper/index.ts
Normal file
5
src/components/WorkflowStepper/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* WorkflowStepper component barrel export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { WorkflowStepper } from "./WorkflowStepper";
|
||||||
20
src/constants/workflowSteps.ts
Normal file
20
src/constants/workflowSteps.ts
Normal 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;
|
||||||
22
src/utils/displayFilename.ts
Normal file
22
src/utils/displayFilename.ts
Normal 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 ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/utils/threadMetadata.ts
Normal file
82
src/utils/threadMetadata.ts
Normal 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(" • ");
|
||||||
|
}
|
||||||
195
src/utils/workflowGuideContent.ts
Normal file
195
src/utils/workflowGuideContent.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/utils/workflowStepCalculation.ts
Normal file
56
src/utils/workflowStepCalculation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue