mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
|
|
import { usePatternStore } from "../stores/usePatternStore";
|
|
import { useUIStore } from "../stores/useUIStore";
|
|
import {
|
|
convertPesToPen,
|
|
type PesPatternData,
|
|
} from "../formats/import/pesImporter";
|
|
import {
|
|
canUploadPattern,
|
|
getMachineStateCategory,
|
|
} from "../utils/machineStateHelpers";
|
|
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";
|
|
|
|
export function FileUpload() {
|
|
// Machine store
|
|
const {
|
|
isConnected,
|
|
machineStatus,
|
|
uploadProgress,
|
|
isUploading,
|
|
machineInfo,
|
|
resumeAvailable,
|
|
resumeFileName,
|
|
uploadPattern,
|
|
} = useMachineStore(
|
|
useShallow((state) => ({
|
|
isConnected: state.isConnected,
|
|
machineStatus: state.machineStatus,
|
|
uploadProgress: state.uploadProgress,
|
|
isUploading: state.isUploading,
|
|
machineInfo: state.machineInfo,
|
|
resumeAvailable: state.resumeAvailable,
|
|
resumeFileName: state.resumeFileName,
|
|
uploadPattern: state.uploadPattern,
|
|
})),
|
|
);
|
|
|
|
// Pattern store
|
|
const {
|
|
pesData: pesDataProp,
|
|
currentFileName,
|
|
patternOffset,
|
|
setPattern,
|
|
} = usePatternStore(
|
|
useShallow((state) => ({
|
|
pesData: state.pesData,
|
|
currentFileName: state.currentFileName,
|
|
patternOffset: state.patternOffset,
|
|
setPattern: state.setPattern,
|
|
})),
|
|
);
|
|
|
|
// 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 || "";
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const handleFileChange = useCallback(
|
|
async (event?: React.ChangeEvent<HTMLInputElement>) => {
|
|
setIsLoading(true);
|
|
try {
|
|
// Wait for Pyodide if it's still loading
|
|
if (!pyodideReady) {
|
|
console.log("[FileUpload] Waiting for Pyodide to finish loading...");
|
|
await initializePyodide();
|
|
console.log("[FileUpload] Pyodide ready");
|
|
}
|
|
|
|
let file: File | null = null;
|
|
|
|
// In Electron, use native file dialogs
|
|
if (fileService.hasNativeDialogs()) {
|
|
file = await fileService.openFileDialog({ accept: ".pes" });
|
|
} else {
|
|
// In browser, use the input element
|
|
file = event?.target.files?.[0] || null;
|
|
}
|
|
|
|
if (!file) {
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
const data = await convertPesToPen(file);
|
|
setLocalPesData(data);
|
|
setFileName(file.name);
|
|
setPattern(data, file.name);
|
|
} catch (err) {
|
|
alert(
|
|
`Failed to load PES file: ${
|
|
err instanceof Error ? err.message : "Unknown error"
|
|
}`,
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[fileService, setPattern, pyodideReady, initializePyodide],
|
|
);
|
|
|
|
const handleUpload = useCallback(() => {
|
|
if (pesData && displayFileName) {
|
|
uploadPattern(pesData.penData, pesData, displayFileName, patternOffset);
|
|
}
|
|
}, [pesData, displayFileName, uploadPattern, patternOffset]);
|
|
|
|
// Check if pattern (with offset) fits within hoop bounds
|
|
const checkPatternFitsInHoop = useCallback(() => {
|
|
if (!pesData || !machineInfo) {
|
|
return { fits: true, error: null };
|
|
}
|
|
|
|
const { bounds } = pesData;
|
|
const { maxWidth, maxHeight } = machineInfo;
|
|
|
|
// Calculate pattern bounds with offset applied
|
|
const patternMinX = bounds.minX + patternOffset.x;
|
|
const patternMaxX = bounds.maxX + patternOffset.x;
|
|
const patternMinY = bounds.minY + patternOffset.y;
|
|
const patternMaxY = bounds.maxY + patternOffset.y;
|
|
|
|
// Hoop bounds (centered at origin)
|
|
const hoopMinX = -maxWidth / 2;
|
|
const hoopMaxX = maxWidth / 2;
|
|
const hoopMinY = -maxHeight / 2;
|
|
const hoopMaxY = maxHeight / 2;
|
|
|
|
// Check if pattern exceeds hoop bounds
|
|
const exceedsLeft = patternMinX < hoopMinX;
|
|
const exceedsRight = patternMaxX > hoopMaxX;
|
|
const exceedsTop = patternMinY < hoopMinY;
|
|
const exceedsBottom = patternMaxY > hoopMaxY;
|
|
|
|
if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) {
|
|
const directions = [];
|
|
if (exceedsLeft)
|
|
directions.push(
|
|
`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`,
|
|
);
|
|
if (exceedsRight)
|
|
directions.push(
|
|
`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`,
|
|
);
|
|
if (exceedsTop)
|
|
directions.push(
|
|
`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`,
|
|
);
|
|
if (exceedsBottom)
|
|
directions.push(
|
|
`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`,
|
|
);
|
|
|
|
return {
|
|
fits: false,
|
|
error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`,
|
|
};
|
|
}
|
|
|
|
return { fits: true, error: null };
|
|
}, [pesData, machineInfo, patternOffset]);
|
|
|
|
const boundsCheck = checkPatternFitsInHoop();
|
|
|
|
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 (
|
|
<div
|
|
className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}
|
|
>
|
|
<div className="flex items-start gap-3 mb-3">
|
|
<DocumentTextIcon
|
|
className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
|
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}
|
|
/>
|
|
<label
|
|
htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
|
|
onClick={
|
|
fileService.hasNativeDialogs()
|
|
? () => handleFileChange()
|
|
: undefined
|
|
}
|
|
className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${
|
|
isLoading || patternUploaded || isUploading
|
|
? "opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white"
|
|
: "cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600"
|
|
}`}
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<svg
|
|
className="w-3.5 h-3.5 animate-spin"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
></circle>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
<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>
|
|
|
|
{pesData &&
|
|
canUploadPattern(machineStatus) &&
|
|
!patternUploaded &&
|
|
uploadProgress < 100 && (
|
|
<button
|
|
onClick={handleUpload}
|
|
disabled={!isConnected || isUploading || !boundsCheck.fits}
|
|
className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
|
aria-label={
|
|
isUploading
|
|
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
|
|
: boundsCheck.error || "Upload pattern to machine"
|
|
}
|
|
>
|
|
{isUploading ? (
|
|
<>
|
|
<svg
|
|
className="w-3.5 h-3.5 animate-spin inline mr-1"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
></circle>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
{uploadProgress > 0
|
|
? uploadProgress.toFixed(0) + "%"
|
|
: "Uploading"}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
|
|
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>
|
|
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-primary-500 via-primary-600 to-primary-700 dark:from-primary-600 dark:via-primary-700 dark:to-primary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
|
|
style={{ width: `${pyodideProgress}%` }}
|
|
/>
|
|
</div>
|
|
<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) && (
|
|
<div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm">
|
|
Cannot upload while {getMachineStateCategory(machineStatus)}
|
|
</div>
|
|
)}
|
|
|
|
{pesData && boundsCheck.error && (
|
|
<div className="bg-danger-100 dark:bg-danger-900/20 text-danger-800 dark:text-danger-200 px-3 py-2 rounded border border-danger-200 dark:border-danger-800 text-sm">
|
|
<strong>Pattern too large:</strong> {boundsCheck.error}
|
|
</div>
|
|
)}
|
|
</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>
|
|
<div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-secondary-500 via-secondary-600 to-secondary-700 dark:from-secondary-600 dark:via-secondary-700 dark:to-secondary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
|
|
style={{ width: `${uploadProgress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|