From 681ce223c3d0e59d7a45bd74e8c4d8255a43bf7d Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 27 Dec 2025 16:27:17 +0100 Subject: [PATCH] refactor: Extract FileUpload sub-components and utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move FileUpload into dedicated folder with sub-components - Extract FileSelector, PyodideProgress, UploadButton, UploadProgress, BoundsValidator - Create useDisplayFilename hook for filename priority logic - Reduce FileUpload.tsx from 391 to 261 lines (33% reduction) Part of #33: Extract sub-components from large components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/components/FileUpload.tsx | 390 ------------------ src/components/FileUpload/BoundsValidator.tsx | 53 +++ src/components/FileUpload/FileSelector.tsx | 92 +++++ src/components/FileUpload/FileUpload.tsx | 258 ++++++++++++ src/components/FileUpload/PyodideProgress.tsx | 44 ++ src/components/FileUpload/UploadButton.tsx | 67 +++ src/components/FileUpload/UploadProgress.tsx | 36 ++ src/components/FileUpload/index.ts | 5 + src/hooks/domain/useDisplayFilename.ts | 22 + 9 files changed, 577 insertions(+), 390 deletions(-) delete mode 100644 src/components/FileUpload.tsx create mode 100644 src/components/FileUpload/BoundsValidator.tsx create mode 100644 src/components/FileUpload/FileSelector.tsx create mode 100644 src/components/FileUpload/FileUpload.tsx create mode 100644 src/components/FileUpload/PyodideProgress.tsx create mode 100644 src/components/FileUpload/UploadButton.tsx create mode 100644 src/components/FileUpload/UploadProgress.tsx create mode 100644 src/components/FileUpload/index.ts create mode 100644 src/hooks/domain/useDisplayFilename.ts diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx deleted file mode 100644 index f89990d..0000000 --- a/src/components/FileUpload.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { useState, useCallback } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; -import { useMachineUploadStore } from "../stores/useMachineUploadStore"; -import { useMachineCacheStore } from "../stores/useMachineCacheStore"; -import { usePatternStore } from "../stores/usePatternStore"; -import { useUIStore } from "../stores/useUIStore"; -import type { PesPatternData } from "../formats/import/pesImporter"; -import { - canUploadPattern, - getMachineStateCategory, -} from "../utils/machineStateHelpers"; -import { - useFileUpload, - usePatternRotationUpload, - usePatternValidation, -} from "@/hooks"; -import { PatternInfoSkeleton } from "./SkeletonLoader"; -import { PatternInfo } from "./PatternInfo"; -import { - ArrowUpTrayIcon, - CheckCircleIcon, - DocumentTextIcon, - FolderOpenIcon, -} from "@heroicons/react/24/solid"; -import { createFileService } from "../platform"; -import type { IFileService } from "../platform/interfaces/IFileService"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Progress } from "@/components/ui/progress"; -import { Loader2 } from "lucide-react"; -import { cn } from "@/lib/utils"; - -export function FileUpload() { - // Machine store - const { isConnected, machineStatus, machineInfo } = useMachineStore( - useShallow((state) => ({ - isConnected: state.isConnected, - machineStatus: state.machineStatus, - machineInfo: state.machineInfo, - })), - ); - - // Machine upload store - const { uploadProgress, isUploading, uploadPattern } = useMachineUploadStore( - useShallow((state) => ({ - uploadProgress: state.uploadProgress, - isUploading: state.isUploading, - uploadPattern: state.uploadPattern, - })), - ); - - // Machine cache store - const { resumeAvailable, resumeFileName } = useMachineCacheStore( - useShallow((state) => ({ - resumeAvailable: state.resumeAvailable, - resumeFileName: state.resumeFileName, - })), - ); - - // Pattern store - const { - pesData: pesDataProp, - currentFileName, - patternOffset, - patternRotation, - setPattern, - setUploadedPattern, - } = usePatternStore( - useShallow((state) => ({ - pesData: state.pesData, - currentFileName: state.currentFileName, - patternOffset: state.patternOffset, - patternRotation: state.patternRotation, - setPattern: state.setPattern, - setUploadedPattern: state.setUploadedPattern, - })), - ); - - // Derived state: pattern is uploaded if machine has pattern info - const patternUploaded = usePatternUploaded(); - - // UI store - const { - pyodideReady, - pyodideProgress, - pyodideLoadingStep, - initializePyodide, - } = useUIStore( - useShallow((state) => ({ - pyodideReady: state.pyodideReady, - pyodideProgress: state.pyodideProgress, - pyodideLoadingStep: state.pyodideLoadingStep, - initializePyodide: state.initializePyodide, - })), - ); - const [localPesData, setLocalPesData] = useState(null); - const [fileName, setFileName] = useState(""); - const [fileService] = useState(() => createFileService()); - - // Use prop pesData if available (from cached pattern), otherwise use local state - const pesData = pesDataProp || localPesData; - // Use currentFileName from App state, or local fileName, or resumeFileName for display - const displayFileName = currentFileName || fileName || resumeFileName || ""; - - // File upload hook - handles file selection and conversion - const { isLoading, handleFileChange } = useFileUpload({ - fileService, - pyodideReady, - initializePyodide, - onFileLoaded: useCallback( - (data: PesPatternData, name: string) => { - setLocalPesData(data); - setFileName(name); - setPattern(data, name); - }, - [setPattern], - ), - }); - - // Pattern rotation and upload hook - handles rotation transformation - const { handleUpload: handlePatternUpload } = usePatternRotationUpload({ - uploadPattern, - setUploadedPattern, - }); - - // Wrapper to call upload with current pattern data - const handleUpload = useCallback(async () => { - if (pesData && displayFileName) { - await handlePatternUpload( - pesData, - displayFileName, - patternOffset, - patternRotation, - ); - } - }, [ - pesData, - displayFileName, - patternOffset, - patternRotation, - handlePatternUpload, - ]); - - // Pattern validation hook - checks if pattern fits in hoop - const boundsCheck = usePatternValidation({ - pesData, - machineInfo, - patternOffset, - patternRotation, - }); - - const borderColor = pesData - ? "border-secondary-600 dark:border-secondary-500" - : "border-gray-400 dark:border-gray-600"; - const iconColor = pesData - ? "text-secondary-600 dark:text-secondary-400" - : "text-gray-600 dark:text-gray-400"; - - return ( - - -
- -
-

- Pattern File -

- {pesData && displayFileName ? ( -

- {displayFileName} -

- ) : ( -

- No pattern loaded -

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

- Cached: "{resumeFileName}" -

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

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

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

+ Pattern File +

+ {pesData && displayFileName ? ( +

+ {displayFileName} +

+ ) : ( +

+ No pattern loaded +

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

+ Cached: "{resumeFileName}" +

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

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

+
+ ); +} diff --git a/src/components/FileUpload/UploadButton.tsx b/src/components/FileUpload/UploadButton.tsx new file mode 100644 index 0000000..f933729 --- /dev/null +++ b/src/components/FileUpload/UploadButton.tsx @@ -0,0 +1,67 @@ +/** + * 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; + boundsError: string | null; + onUpload: () => Promise; + patternUploaded: boolean; +} + +export function UploadButton({ + pesData, + machineStatus, + isConnected, + isUploading, + uploadProgress, + boundsError, + onUpload, + patternUploaded, +}: UploadButtonProps) { + const shouldShow = + pesData && + canUploadPattern(machineStatus) && + !patternUploaded && + uploadProgress < 100; + + if (!shouldShow) return null; + + return ( + + ); +} diff --git a/src/components/FileUpload/UploadProgress.tsx b/src/components/FileUpload/UploadProgress.tsx new file mode 100644 index 0000000..d41babb --- /dev/null +++ b/src/components/FileUpload/UploadProgress.tsx @@ -0,0 +1,36 @@ +/** + * UploadProgress Component + * + * Renders upload progress bar + */ + +import { Progress } from "@/components/ui/progress"; + +interface UploadProgressProps { + isUploading: boolean; + uploadProgress: number; +} + +export function UploadProgress({ + isUploading, + uploadProgress, +}: UploadProgressProps) { + if (!isUploading || uploadProgress >= 100) return null; + + return ( +
+
+ + Uploading + + + {uploadProgress > 0 ? uploadProgress.toFixed(1) + "%" : "Starting..."} + +
+ +
+ ); +} diff --git a/src/components/FileUpload/index.ts b/src/components/FileUpload/index.ts new file mode 100644 index 0000000..61f5b4a --- /dev/null +++ b/src/components/FileUpload/index.ts @@ -0,0 +1,5 @@ +/** + * FileUpload component barrel export + */ + +export { FileUpload } from "./FileUpload"; diff --git a/src/hooks/domain/useDisplayFilename.ts b/src/hooks/domain/useDisplayFilename.ts new file mode 100644 index 0000000..f239ed2 --- /dev/null +++ b/src/hooks/domain/useDisplayFilename.ts @@ -0,0 +1,22 @@ +/** + * useDisplayFilename Hook + * + * 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 useDisplayFilename(options: { + currentFileName: string | null; + localFileName: string; + resumeFileName: string | null; +}): string { + return ( + options.currentFileName || + options.localFileName || + options.resumeFileName || + "" + ); +}