mirror of
https://github.com/jhbruhn/respira.git
synced 2026-03-13 18:28:41 +00:00
Compare commits
No commits in common. "a173ee33a491e7440563b92a988dd5e3d3a6e5a6" and "2372278081bd6e3c3ceeec67f0305e88e5a3d05d" have entirely different histories.
a173ee33a4
...
2372278081
67 changed files with 1475 additions and 4843 deletions
|
|
@ -1,286 +0,0 @@
|
||||||
---
|
|
||||||
name: react-specialist
|
|
||||||
description: Expert React specialist mastering React 18+ with modern patterns and ecosystem. Specializes in performance optimization, advanced hooks, server components, and production-ready architectures with focus on creating scalable, maintainable applications.
|
|
||||||
tools: Read, Write, Edit, Bash, Glob, Grep
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a senior React specialist with expertise in React 18+ and the modern React ecosystem. Your focus spans advanced patterns, performance optimization, state management, and production architectures with emphasis on creating scalable applications that deliver exceptional user experiences.
|
|
||||||
|
|
||||||
|
|
||||||
When invoked:
|
|
||||||
1. Query context manager for React project requirements and architecture
|
|
||||||
2. Review component structure, state management, and performance needs
|
|
||||||
3. Analyze optimization opportunities, patterns, and best practices
|
|
||||||
4. Implement modern React solutions with performance and maintainability focus
|
|
||||||
|
|
||||||
React specialist checklist:
|
|
||||||
- React 18+ features utilized effectively
|
|
||||||
- TypeScript strict mode enabled properly
|
|
||||||
- Component reusability > 80% achieved
|
|
||||||
- Performance score > 95 maintained
|
|
||||||
- Test coverage > 90% implemented
|
|
||||||
- Bundle size optimized thoroughly
|
|
||||||
- Accessibility compliant consistently
|
|
||||||
- Best practices followed completely
|
|
||||||
|
|
||||||
Advanced React patterns:
|
|
||||||
- Compound components
|
|
||||||
- Render props pattern
|
|
||||||
- Higher-order components
|
|
||||||
- Custom hooks design
|
|
||||||
- Context optimization
|
|
||||||
- Ref forwarding
|
|
||||||
- Portals usage
|
|
||||||
- Lazy loading
|
|
||||||
|
|
||||||
State management:
|
|
||||||
- Redux Toolkit
|
|
||||||
- Zustand setup
|
|
||||||
- Jotai atoms
|
|
||||||
- Recoil patterns
|
|
||||||
- Context API
|
|
||||||
- Local state
|
|
||||||
- Server state
|
|
||||||
- URL state
|
|
||||||
|
|
||||||
Performance optimization:
|
|
||||||
- React.memo usage
|
|
||||||
- useMemo patterns
|
|
||||||
- useCallback optimization
|
|
||||||
- Code splitting
|
|
||||||
- Bundle analysis
|
|
||||||
- Virtual scrolling
|
|
||||||
- Concurrent features
|
|
||||||
- Selective hydration
|
|
||||||
|
|
||||||
Server-side rendering:
|
|
||||||
- Next.js integration
|
|
||||||
- Remix patterns
|
|
||||||
- Server components
|
|
||||||
- Streaming SSR
|
|
||||||
- Progressive enhancement
|
|
||||||
- SEO optimization
|
|
||||||
- Data fetching
|
|
||||||
- Hydration strategies
|
|
||||||
|
|
||||||
Testing strategies:
|
|
||||||
- React Testing Library
|
|
||||||
- Jest configuration
|
|
||||||
- Cypress E2E
|
|
||||||
- Component testing
|
|
||||||
- Hook testing
|
|
||||||
- Integration tests
|
|
||||||
- Performance testing
|
|
||||||
- Accessibility testing
|
|
||||||
|
|
||||||
React ecosystem:
|
|
||||||
- React Query/TanStack
|
|
||||||
- React Hook Form
|
|
||||||
- Framer Motion
|
|
||||||
- React Spring
|
|
||||||
- Material-UI
|
|
||||||
- Ant Design
|
|
||||||
- Tailwind CSS
|
|
||||||
- Styled Components
|
|
||||||
|
|
||||||
Component patterns:
|
|
||||||
- Atomic design
|
|
||||||
- Container/presentational
|
|
||||||
- Controlled components
|
|
||||||
- Error boundaries
|
|
||||||
- Suspense boundaries
|
|
||||||
- Portal patterns
|
|
||||||
- Fragment usage
|
|
||||||
- Children patterns
|
|
||||||
|
|
||||||
Hooks mastery:
|
|
||||||
- useState patterns
|
|
||||||
- useEffect optimization
|
|
||||||
- useContext best practices
|
|
||||||
- useReducer complex state
|
|
||||||
- useMemo calculations
|
|
||||||
- useCallback functions
|
|
||||||
- useRef DOM/values
|
|
||||||
- Custom hooks library
|
|
||||||
|
|
||||||
Concurrent features:
|
|
||||||
- useTransition
|
|
||||||
- useDeferredValue
|
|
||||||
- Suspense for data
|
|
||||||
- Error boundaries
|
|
||||||
- Streaming HTML
|
|
||||||
- Progressive hydration
|
|
||||||
- Selective hydration
|
|
||||||
- Priority scheduling
|
|
||||||
|
|
||||||
Migration strategies:
|
|
||||||
- Class to function components
|
|
||||||
- Legacy lifecycle methods
|
|
||||||
- State management migration
|
|
||||||
- Testing framework updates
|
|
||||||
- Build tool migration
|
|
||||||
- TypeScript adoption
|
|
||||||
- Performance upgrades
|
|
||||||
- Gradual modernization
|
|
||||||
|
|
||||||
## Communication Protocol
|
|
||||||
|
|
||||||
### React Context Assessment
|
|
||||||
|
|
||||||
Initialize React development by understanding project requirements.
|
|
||||||
|
|
||||||
React context query:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"requesting_agent": "react-specialist",
|
|
||||||
"request_type": "get_react_context",
|
|
||||||
"payload": {
|
|
||||||
"query": "React context needed: project type, performance requirements, state management approach, testing strategy, and deployment target."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
Execute React development through systematic phases:
|
|
||||||
|
|
||||||
### 1. Architecture Planning
|
|
||||||
|
|
||||||
Design scalable React architecture.
|
|
||||||
|
|
||||||
Planning priorities:
|
|
||||||
- Component structure
|
|
||||||
- State management
|
|
||||||
- Routing strategy
|
|
||||||
- Performance goals
|
|
||||||
- Testing approach
|
|
||||||
- Build configuration
|
|
||||||
- Deployment pipeline
|
|
||||||
- Team conventions
|
|
||||||
|
|
||||||
Architecture design:
|
|
||||||
- Define structure
|
|
||||||
- Plan components
|
|
||||||
- Design state flow
|
|
||||||
- Set performance targets
|
|
||||||
- Create testing strategy
|
|
||||||
- Configure build tools
|
|
||||||
- Setup CI/CD
|
|
||||||
- Document patterns
|
|
||||||
|
|
||||||
### 2. Implementation Phase
|
|
||||||
|
|
||||||
Build high-performance React applications.
|
|
||||||
|
|
||||||
Implementation approach:
|
|
||||||
- Create components
|
|
||||||
- Implement state
|
|
||||||
- Add routing
|
|
||||||
- Optimize performance
|
|
||||||
- Write tests
|
|
||||||
- Handle errors
|
|
||||||
- Add accessibility
|
|
||||||
- Deploy application
|
|
||||||
|
|
||||||
React patterns:
|
|
||||||
- Component composition
|
|
||||||
- State management
|
|
||||||
- Effect management
|
|
||||||
- Performance optimization
|
|
||||||
- Error handling
|
|
||||||
- Code splitting
|
|
||||||
- Progressive enhancement
|
|
||||||
- Testing coverage
|
|
||||||
|
|
||||||
Progress tracking:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent": "react-specialist",
|
|
||||||
"status": "implementing",
|
|
||||||
"progress": {
|
|
||||||
"components_created": 47,
|
|
||||||
"test_coverage": "92%",
|
|
||||||
"performance_score": 98,
|
|
||||||
"bundle_size": "142KB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. React Excellence
|
|
||||||
|
|
||||||
Deliver exceptional React applications.
|
|
||||||
|
|
||||||
Excellence checklist:
|
|
||||||
- Performance optimized
|
|
||||||
- Tests comprehensive
|
|
||||||
- Accessibility complete
|
|
||||||
- Bundle minimized
|
|
||||||
- SEO optimized
|
|
||||||
- Errors handled
|
|
||||||
- Documentation clear
|
|
||||||
- Deployment smooth
|
|
||||||
|
|
||||||
Delivery notification:
|
|
||||||
"React application completed. Created 47 components with 92% test coverage. Achieved 98 performance score with 142KB bundle size. Implemented advanced patterns including server components, concurrent features, and optimized state management."
|
|
||||||
|
|
||||||
Performance excellence:
|
|
||||||
- Load time < 2s
|
|
||||||
- Time to interactive < 3s
|
|
||||||
- First contentful paint < 1s
|
|
||||||
- Core Web Vitals passed
|
|
||||||
- Bundle size minimal
|
|
||||||
- Code splitting effective
|
|
||||||
- Caching optimized
|
|
||||||
- CDN configured
|
|
||||||
|
|
||||||
Testing excellence:
|
|
||||||
- Unit tests complete
|
|
||||||
- Integration tests thorough
|
|
||||||
- E2E tests reliable
|
|
||||||
- Visual regression tests
|
|
||||||
- Performance tests
|
|
||||||
- Accessibility tests
|
|
||||||
- Snapshot tests
|
|
||||||
- Coverage reports
|
|
||||||
|
|
||||||
Architecture excellence:
|
|
||||||
- Components reusable
|
|
||||||
- State predictable
|
|
||||||
- Side effects managed
|
|
||||||
- Errors handled gracefully
|
|
||||||
- Performance monitored
|
|
||||||
- Security implemented
|
|
||||||
- Deployment automated
|
|
||||||
- Monitoring active
|
|
||||||
|
|
||||||
Modern features:
|
|
||||||
- Server components
|
|
||||||
- Streaming SSR
|
|
||||||
- React transitions
|
|
||||||
- Concurrent rendering
|
|
||||||
- Automatic batching
|
|
||||||
- Suspense for data
|
|
||||||
- Error boundaries
|
|
||||||
- Hydration optimization
|
|
||||||
|
|
||||||
Best practices:
|
|
||||||
- TypeScript strict
|
|
||||||
- ESLint configured
|
|
||||||
- Prettier formatting
|
|
||||||
- Husky pre-commit
|
|
||||||
- Conventional commits
|
|
||||||
- Semantic versioning
|
|
||||||
- Documentation complete
|
|
||||||
- Code reviews thorough
|
|
||||||
|
|
||||||
Integration with other agents:
|
|
||||||
- Collaborate with frontend-developer on UI patterns
|
|
||||||
- Support fullstack-developer on React integration
|
|
||||||
- Work with typescript-pro on type safety
|
|
||||||
- Guide javascript-pro on modern JavaScript
|
|
||||||
- Help performance-engineer on optimization
|
|
||||||
- Assist qa-expert on testing strategies
|
|
||||||
- Partner with accessibility-specialist on a11y
|
|
||||||
- Coordinate with devops-engineer on deployment
|
|
||||||
|
|
||||||
Always prioritize performance, maintainability, and user experience while building React applications that scale effectively and deliver exceptional results.
|
|
||||||
|
|
@ -9,9 +9,7 @@
|
||||||
"Bash(npm test:*)",
|
"Bash(npm test:*)",
|
||||||
"Bash(npm run:*)",
|
"Bash(npm run:*)",
|
||||||
"Bash(gh issue create:*)",
|
"Bash(gh issue create:*)",
|
||||||
"Bash(gh label create:*)",
|
"Bash(gh label create:*)"
|
||||||
"Bash(gh issue view:*)",
|
|
||||||
"Bash(gh pr view:*)"
|
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
11
.mcp.json
11
.mcp.json
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"shadcn": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"shadcn@latest",
|
|
||||||
"mcp"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
698
package-lock.json
generated
698
package-lock.json
generated
|
|
@ -48,9 +48,6 @@
|
||||||
"@electron/typescript-definitions": "^8.15.6",
|
"@electron/typescript-definitions": "^8.15.6",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@reforged/maker-appimage": "^5.1.1",
|
"@reforged/maker-appimage": "^5.1.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
|
||||||
"@testing-library/react": "^16.3.1",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@types/electron-squirrel-startup": "^1.0.2",
|
"@types/electron-squirrel-startup": "^1.0.2",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
|
|
@ -65,7 +62,6 @@
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.7.4",
|
||||||
"shadcn": "^3.6.2",
|
"shadcn": "^3.6.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
|
|
@ -75,13 +71,6 @@
|
||||||
"vitest": "^4.0.15"
|
"vitest": "^4.0.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@acemir/cssom": {
|
|
||||||
"version": "0.9.30",
|
|
||||||
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz",
|
|
||||||
"integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@antfu/ni": {
|
"node_modules/@antfu/ni": {
|
||||||
"version": "25.0.0",
|
"version": "25.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz",
|
||||||
|
|
@ -104,61 +93,6 @@
|
||||||
"nup": "bin/nup.mjs"
|
"nup": "bin/nup.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@csstools/css-calc": "^2.1.4",
|
|
||||||
"@csstools/css-color-parser": "^3.1.0",
|
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
|
||||||
"lru-cache": "^11.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
|
||||||
"version": "11.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@asamuzakjp/dom-selector": {
|
|
||||||
"version": "6.7.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
|
|
||||||
"integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
|
||||||
"bidi-js": "^1.0.3",
|
|
||||||
"css-tree": "^3.1.0",
|
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
|
||||||
"lru-cache": "^11.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
|
||||||
"version": "11.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@asamuzakjp/nwsapi": {
|
|
||||||
"version": "2.3.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
|
||||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
|
|
@ -664,141 +598,6 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
|
||||||
"version": "5.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
|
||||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/csstools"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/csstools"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT-0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@csstools/css-calc": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/csstools"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/csstools"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@csstools/css-color-parser": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/csstools"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/csstools"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@csstools/color-helpers": "^5.1.0",
|
|
||||||
"@csstools/css-calc": "^2.1.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@csstools/css-parser-algorithms": {
|
|
||||||
"version": "3.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
|
||||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/csstools"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/csstools"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
|
||||||
"version": "1.0.22",
|
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz",
|
|
||||||
"integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/csstools"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/csstools"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT-0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@csstools/css-tokenizer": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/csstools"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/csstools"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.51.2",
|
"version": "1.51.2",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.2.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.2.tgz",
|
||||||
|
|
@ -2601,24 +2400,6 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@exodus/bytes": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@exodus/crypto": "^1.0.0-rc.4"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@exodus/crypto": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||||
|
|
@ -5276,68 +5057,6 @@
|
||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"vite": "^5.2.0 || ^6 || ^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
|
||||||
"version": "10.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
|
||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/code-frame": "^7.10.4",
|
|
||||||
"@babel/runtime": "^7.12.5",
|
|
||||||
"@types/aria-query": "^5.0.1",
|
|
||||||
"aria-query": "5.3.0",
|
|
||||||
"dom-accessibility-api": "^0.5.9",
|
|
||||||
"lz-string": "^1.5.0",
|
|
||||||
"picocolors": "1.1.1",
|
|
||||||
"pretty-format": "^27.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/react": {
|
|
||||||
"version": "16.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
|
|
||||||
"integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.12.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@testing-library/dom": "^10.0.0",
|
|
||||||
"@types/react": "^18.0.0 || ^19.0.0",
|
|
||||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/user-event": {
|
|
||||||
"version": "14.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
|
||||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12",
|
|
||||||
"npm": ">=6"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@testing-library/dom": ">=7.21.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tokenizer/token": {
|
"node_modules/@tokenizer/token": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||||
|
|
@ -5394,13 +5113,6 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/aria-query": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -6653,16 +6365,6 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aria-query": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"dequal": "^2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/array-union": {
|
"node_modules/array-union": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||||
|
|
@ -6837,16 +6539,6 @@
|
||||||
"tweetnacl": "^0.14.3"
|
"tweetnacl": "^0.14.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bidi-js": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"require-from-string": "^2.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
|
@ -7940,20 +7632,6 @@
|
||||||
"node": ">=12.10"
|
"node": ">=12.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/css-tree": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mdn-data": "2.12.2",
|
|
||||||
"source-map-js": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
|
@ -7967,21 +7645,6 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle": {
|
|
||||||
"version": "5.3.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz",
|
|
||||||
"integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@asamuzakjp/css-color": "^4.1.1",
|
|
||||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
|
|
||||||
"css-tree": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
|
@ -8011,57 +7674,6 @@
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/data-urls": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-mimetype": "^4.0.0",
|
|
||||||
"whatwg-url": "^15.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/data-urls/node_modules/tr46": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"punycode": "^2.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/data-urls/node_modules/webidl-conversions": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
|
||||||
"version": "15.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
|
|
||||||
"integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "^6.0.0",
|
|
||||||
"webidl-conversions": "^8.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debounce-fn": {
|
"node_modules/debounce-fn": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
|
||||||
|
|
@ -8105,13 +7717,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js": {
|
|
||||||
"version": "10.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
|
||||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
|
@ -8320,16 +7925,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dequal": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|
@ -8387,13 +7982,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
|
||||||
"version": "0.5.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/dom-walk": {
|
"node_modules/dom-walk": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||||
|
|
@ -9248,19 +8836,6 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/entities": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
|
|
@ -11126,19 +10701,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/html-encoding-sniffer": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@exodus/bytes": "^1.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http-cache-semantics": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
|
|
@ -11719,13 +11281,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-potential-custom-element-name": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
|
|
@ -11966,134 +11521,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
|
||||||
"version": "27.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
|
|
||||||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@acemir/cssom": "^0.9.28",
|
|
||||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
|
||||||
"@exodus/bytes": "^1.6.0",
|
|
||||||
"cssstyle": "^5.3.4",
|
|
||||||
"data-urls": "^6.0.0",
|
|
||||||
"decimal.js": "^10.6.0",
|
|
||||||
"html-encoding-sniffer": "^6.0.0",
|
|
||||||
"http-proxy-agent": "^7.0.2",
|
|
||||||
"https-proxy-agent": "^7.0.6",
|
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
|
||||||
"parse5": "^8.0.0",
|
|
||||||
"saxes": "^6.0.0",
|
|
||||||
"symbol-tree": "^3.2.4",
|
|
||||||
"tough-cookie": "^6.0.0",
|
|
||||||
"w3c-xmlserializer": "^5.0.0",
|
|
||||||
"webidl-conversions": "^8.0.0",
|
|
||||||
"whatwg-mimetype": "^4.0.0",
|
|
||||||
"whatwg-url": "^15.1.0",
|
|
||||||
"ws": "^8.18.3",
|
|
||||||
"xml-name-validator": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"canvas": "^3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"canvas": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom/node_modules/agent-base": {
|
|
||||||
"version": "7.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom/node_modules/http-proxy-agent": {
|
|
||||||
"version": "7.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
|
||||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "^7.1.0",
|
|
||||||
"debug": "^4.3.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom/node_modules/https-proxy-agent": {
|
|
||||||
"version": "7.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
|
||||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "^7.1.2",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom/node_modules/tough-cookie": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"tldts": "^7.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom/node_modules/tr46": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"punycode": "^2.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom/node_modules/webidl-conversions": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsdom/node_modules/whatwg-url": {
|
|
||||||
"version": "15.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
|
|
||||||
"integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "^6.0.0",
|
|
||||||
"webidl-conversions": "^8.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
|
|
@ -12899,16 +12326,6 @@
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lz-string": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"lz-string": "bin/bin.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/macos-alias": {
|
"node_modules/macos-alias": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/macos-alias/-/macos-alias-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/macos-alias/-/macos-alias-0.2.12.tgz",
|
||||||
|
|
@ -13008,13 +12425,6 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdn-data": {
|
|
||||||
"version": "2.12.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
|
||||||
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "CC0-1.0"
|
|
||||||
},
|
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
|
|
@ -14197,19 +13607,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/parse5": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"entities": "^6.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|
@ -14676,34 +14073,6 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pretty-format": {
|
|
||||||
"version": "27.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
|
||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^5.0.1",
|
|
||||||
"ansi-styles": "^5.0.0",
|
|
||||||
"react-is": "^17.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
|
||||||
"version": "5.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pretty-ms": {
|
"node_modules/pretty-ms": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz",
|
||||||
|
|
@ -14993,13 +14362,6 @@
|
||||||
"react": "^19.2.3"
|
"react": "^19.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
|
||||||
"version": "17.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/react-konva": {
|
"node_modules/react-konva": {
|
||||||
"version": "19.2.1",
|
"version": "19.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.1.tgz",
|
||||||
|
|
@ -15763,19 +15125,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
"node_modules/saxes": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"xmlchars": "^2.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=v12.22.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
|
@ -17292,13 +16641,6 @@
|
||||||
"camelcase": "^3.0.0"
|
"camelcase": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/symbol-tree": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/synckit": {
|
"node_modules/synckit": {
|
||||||
"version": "0.11.11",
|
"version": "0.11.11",
|
||||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
|
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
|
||||||
|
|
@ -18471,19 +17813,6 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/w3c-xmlserializer": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"xml-name-validator": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/watchpack": {
|
"node_modules/watchpack": {
|
||||||
"version": "2.4.4",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
|
||||||
|
|
@ -18615,16 +17944,6 @@
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/whatwg-mimetype": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-url": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
|
@ -18797,16 +18116,6 @@
|
||||||
"xtend": "^4.0.0"
|
"xtend": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xml-name-validator": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xml-parse-from-string": {
|
"node_modules/xml-parse-from-string": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
|
||||||
|
|
@ -18848,13 +18157,6 @@
|
||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xmlchars": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,6 @@
|
||||||
"@electron/typescript-definitions": "^8.15.6",
|
"@electron/typescript-definitions": "^8.15.6",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@reforged/maker-appimage": "^5.1.1",
|
"@reforged/maker-appimage": "^5.1.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
|
||||||
"@testing-library/react": "^16.3.1",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@types/electron-squirrel-startup": "^1.0.2",
|
"@types/electron-squirrel-startup": "^1.0.2",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
|
|
@ -78,7 +75,6 @@
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.7.4",
|
||||||
"shadcn": "^3.6.2",
|
"shadcn": "^3.6.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useMachineStore } from "../stores/useMachineStore";
|
import { useMachineStore } from "../stores/useMachineStore";
|
||||||
import { useUIStore } from "../stores/useUIStore";
|
import { useUIStore } from "../stores/useUIStore";
|
||||||
import { useErrorPopoverState } from "@/hooks";
|
import { usePrevious } from "../hooks/usePrevious";
|
||||||
import { WorkflowStepper } from "./WorkflowStepper";
|
import { WorkflowStepper } from "./WorkflowStepper";
|
||||||
import { ErrorPopoverContent } from "./ErrorPopover";
|
import { ErrorPopoverContent } from "./ErrorPopover";
|
||||||
import {
|
import {
|
||||||
|
|
@ -60,16 +61,17 @@ export function AppHeader() {
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Error popover state management
|
// State management for error popover auto-open/close
|
||||||
const {
|
const [errorPopoverOpen, setErrorPopoverOpen] = useState(false);
|
||||||
isOpen: errorPopoverOpen,
|
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
|
||||||
handleOpenChange: handlePopoverOpenChange,
|
null,
|
||||||
} = useErrorPopoverState({
|
);
|
||||||
machineError,
|
const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false);
|
||||||
machineErrorMessage,
|
|
||||||
pyodideError,
|
// Track previous values for comparison
|
||||||
hasError,
|
const prevMachineError = usePrevious(machineError);
|
||||||
});
|
const prevErrorMessage = usePrevious(machineErrorMessage);
|
||||||
|
const prevPyodideError = usePrevious(pyodideError);
|
||||||
|
|
||||||
// Get state visual info for header status badge
|
// Get state visual info for header status badge
|
||||||
const stateVisual = getStateVisualInfo(machineStatus);
|
const stateVisual = getStateVisualInfo(machineStatus);
|
||||||
|
|
@ -88,6 +90,67 @@ export function AppHeader() {
|
||||||
? getStatusIndicatorState(machineStatus)
|
? getStatusIndicatorState(machineStatus)
|
||||||
: "idle";
|
: "idle";
|
||||||
|
|
||||||
|
// Auto-open/close error popover based on error state changes
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if there's any error now
|
||||||
|
const hasAnyError =
|
||||||
|
machineErrorMessage || pyodideError || hasError(machineError);
|
||||||
|
// Check if there was any error before
|
||||||
|
const hadAnyError =
|
||||||
|
prevErrorMessage || prevPyodideError || hasError(prevMachineError);
|
||||||
|
|
||||||
|
// Auto-open popover when new error appears (but not if user manually dismissed)
|
||||||
|
const isNewMachineError =
|
||||||
|
hasError(machineError) &&
|
||||||
|
machineError !== prevMachineError &&
|
||||||
|
machineError !== dismissedErrorCode;
|
||||||
|
const isNewErrorMessage =
|
||||||
|
machineErrorMessage && machineErrorMessage !== prevErrorMessage;
|
||||||
|
const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!wasManuallyDismissed &&
|
||||||
|
(isNewMachineError || isNewErrorMessage || isNewPyodideError)
|
||||||
|
) {
|
||||||
|
setErrorPopoverOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close popover when all errors are cleared
|
||||||
|
if (!hasAnyError && hadAnyError) {
|
||||||
|
setErrorPopoverOpen(false);
|
||||||
|
setDismissedErrorCode(null); // Reset dismissed tracking
|
||||||
|
setWasManuallyDismissed(false); // Reset manual dismissal flag
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
machineError,
|
||||||
|
machineErrorMessage,
|
||||||
|
pyodideError,
|
||||||
|
dismissedErrorCode,
|
||||||
|
wasManuallyDismissed,
|
||||||
|
prevMachineError,
|
||||||
|
prevErrorMessage,
|
||||||
|
prevPyodideError,
|
||||||
|
]);
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
|
// Handle manual popover dismiss
|
||||||
|
const handlePopoverOpenChange = (open: boolean) => {
|
||||||
|
setErrorPopoverOpen(open);
|
||||||
|
|
||||||
|
// If user manually closes it while any error is present, remember this to prevent reopening
|
||||||
|
if (
|
||||||
|
!open &&
|
||||||
|
(hasError(machineError) || machineErrorMessage || pyodideError)
|
||||||
|
) {
|
||||||
|
setWasManuallyDismissed(true);
|
||||||
|
// Also track the specific machine error code if present
|
||||||
|
if (hasError(machineError)) {
|
||||||
|
setDismissedErrorCode(machineError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
|
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useBluetoothDeviceListener } from "@/hooks";
|
import type { BluetoothDevice } from "../types/electron";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -11,41 +11,42 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function BluetoothDevicePicker() {
|
export function BluetoothDevicePicker() {
|
||||||
|
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
|
||||||
// Use Bluetooth device listener hook
|
|
||||||
const { devices, isScanning } = useBluetoothDeviceListener((deviceList) => {
|
|
||||||
console.log("[BluetoothPicker] Received device list:", deviceList);
|
|
||||||
// Open the picker when devices are received
|
|
||||||
// Use functional setState to avoid stale closure
|
|
||||||
setIsOpen((prevIsOpen) => {
|
|
||||||
if (!prevIsOpen && deviceList.length >= 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return prevIsOpen;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal and reset when scan completes with no selection
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && !isScanning && devices.length === 0) {
|
// Only set up listener in Electron
|
||||||
const timer = setTimeout(() => {
|
if (window.electronAPI?.onBluetoothDeviceList) {
|
||||||
setIsOpen(false);
|
window.electronAPI.onBluetoothDeviceList((deviceList) => {
|
||||||
}, 2000);
|
console.log("[BluetoothPicker] Received device list:", deviceList);
|
||||||
return () => clearTimeout(timer);
|
setDevices(deviceList);
|
||||||
|
// Open the picker when scan starts (even if empty at first)
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
setIsScanning(true);
|
||||||
|
}
|
||||||
|
// Stop showing scanning state once we have devices
|
||||||
|
if (deviceList.length > 0) {
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [isOpen, isScanning, devices]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleSelectDevice = useCallback((deviceId: string) => {
|
const handleSelectDevice = useCallback((deviceId: string) => {
|
||||||
console.log("[BluetoothPicker] User selected device:", deviceId);
|
console.log("[BluetoothPicker] User selected device:", deviceId);
|
||||||
window.electronAPI?.selectBluetoothDevice(deviceId);
|
window.electronAPI?.selectBluetoothDevice(deviceId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
setDevices([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
console.log("[BluetoothPicker] User cancelled device selection");
|
console.log("[BluetoothPicker] User cancelled device selection");
|
||||||
window.electronAPI?.selectBluetoothDevice("");
|
window.electronAPI?.selectBluetoothDevice("");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
setDevices([]);
|
||||||
|
setIsScanning(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
388
src/components/FileUpload.tsx
Normal file
388
src/components/FileUpload.tsx
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
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 } from "../hooks/useFileUpload";
|
||||||
|
import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload";
|
||||||
|
import { usePatternValidation } from "../hooks/usePatternValidation";
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/**
|
|
||||||
* FileUpload component barrel export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { FileUpload } from "./FileUpload";
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useMemo } from "react";
|
import { useRef } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import {
|
import {
|
||||||
useMachineStore,
|
useMachineStore,
|
||||||
|
|
@ -21,7 +21,8 @@ import { ThreadLegend } from "./ThreadLegend";
|
||||||
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
import { PatternPositionIndicator } from "./PatternPositionIndicator";
|
||||||
import { ZoomControls } from "./ZoomControls";
|
import { ZoomControls } from "./ZoomControls";
|
||||||
import { PatternLayer } from "./PatternLayer";
|
import { PatternLayer } from "./PatternLayer";
|
||||||
import { useCanvasViewport, usePatternTransform } from "@/hooks";
|
import { useCanvasViewport } from "../../hooks/useCanvasViewport";
|
||||||
|
import { usePatternTransform } from "../../hooks/usePatternTransform";
|
||||||
|
|
||||||
export function PatternCanvas() {
|
export function PatternCanvas() {
|
||||||
// Machine store
|
// Machine store
|
||||||
|
|
@ -109,26 +110,6 @@ export function PatternCanvas() {
|
||||||
? "text-tertiary-600 dark:text-tertiary-400"
|
? "text-tertiary-600 dark:text-tertiary-400"
|
||||||
: "text-gray-600 dark:text-gray-400";
|
: "text-gray-600 dark:text-gray-400";
|
||||||
|
|
||||||
// Memoize the display pattern to avoid recalculation
|
|
||||||
const displayPattern = useMemo(
|
|
||||||
() => uploadedPesData || pesData,
|
|
||||||
[uploadedPesData, pesData],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize pattern dimensions calculation
|
|
||||||
const patternDimensions = useMemo(() => {
|
|
||||||
if (!displayPattern) return null;
|
|
||||||
const width = (
|
|
||||||
(displayPattern.bounds.maxX - displayPattern.bounds.minX) /
|
|
||||||
10
|
|
||||||
).toFixed(1);
|
|
||||||
const height = (
|
|
||||||
(displayPattern.bounds.maxY - displayPattern.bounds.minY) /
|
|
||||||
10
|
|
||||||
).toFixed(1);
|
|
||||||
return `${width} × ${height} mm`;
|
|
||||||
}, [displayPattern]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
|
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
|
||||||
|
|
@ -140,7 +121,25 @@ export function PatternCanvas() {
|
||||||
<CardTitle className="text-sm">Pattern Preview</CardTitle>
|
<CardTitle className="text-sm">Pattern Preview</CardTitle>
|
||||||
{hasPattern ? (
|
{hasPattern ? (
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
{patternDimensions}
|
{(() => {
|
||||||
|
const displayPattern = uploadedPesData || pesData;
|
||||||
|
return displayPattern ? (
|
||||||
|
<>
|
||||||
|
{(
|
||||||
|
(displayPattern.bounds.maxX -
|
||||||
|
displayPattern.bounds.minX) /
|
||||||
|
10
|
||||||
|
).toFixed(1)}{" "}
|
||||||
|
×{" "}
|
||||||
|
{(
|
||||||
|
(displayPattern.bounds.maxY -
|
||||||
|
displayPattern.bounds.minY) /
|
||||||
|
10
|
||||||
|
).toFixed(1)}{" "}
|
||||||
|
mm
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
) : (
|
) : (
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
|
|
@ -184,11 +183,11 @@ export function PatternCanvas() {
|
||||||
>
|
>
|
||||||
{/* Background layer: grid, origin, hoop */}
|
{/* Background layer: grid, origin, hoop */}
|
||||||
<Layer>
|
<Layer>
|
||||||
{displayPattern && (
|
{hasPattern && (
|
||||||
<>
|
<>
|
||||||
<Grid
|
<Grid
|
||||||
gridSize={100}
|
gridSize={100}
|
||||||
bounds={displayPattern.bounds}
|
bounds={(uploadedPesData || pesData)!.bounds}
|
||||||
machineInfo={machineInfo}
|
machineInfo={machineInfo}
|
||||||
/>
|
/>
|
||||||
<Origin />
|
<Origin />
|
||||||
|
|
@ -243,36 +242,42 @@ export function PatternCanvas() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pattern info overlays */}
|
{/* Pattern info overlays */}
|
||||||
{displayPattern && (
|
{hasPattern &&
|
||||||
<>
|
(() => {
|
||||||
<ThreadLegend colors={displayPattern.uniqueColors} />
|
const displayPattern = uploadedPesData || pesData;
|
||||||
|
return (
|
||||||
|
displayPattern && (
|
||||||
|
<>
|
||||||
|
<ThreadLegend colors={displayPattern.uniqueColors} />
|
||||||
|
|
||||||
<PatternPositionIndicator
|
<PatternPositionIndicator
|
||||||
offset={
|
offset={
|
||||||
isUploading || patternUploaded || uploadedPesData
|
isUploading || patternUploaded || uploadedPesData
|
||||||
? initialUploadedPatternOffset
|
? initialUploadedPatternOffset
|
||||||
: localPatternOffset
|
: localPatternOffset
|
||||||
}
|
}
|
||||||
rotation={localPatternRotation}
|
rotation={localPatternRotation}
|
||||||
isLocked={patternUploaded || !!uploadedPesData}
|
isLocked={patternUploaded || !!uploadedPesData}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ZoomControls
|
<ZoomControls
|
||||||
scale={stageScale}
|
scale={stageScale}
|
||||||
onZoomIn={handleZoomIn}
|
onZoomIn={handleZoomIn}
|
||||||
onZoomOut={handleZoomOut}
|
onZoomOut={handleZoomOut}
|
||||||
onZoomReset={handleZoomReset}
|
onZoomReset={handleZoomReset}
|
||||||
onCenterPattern={handleCenterPattern}
|
onCenterPattern={handleCenterPattern}
|
||||||
canCenterPattern={
|
canCenterPattern={
|
||||||
!!pesData &&
|
!!pesData &&
|
||||||
!patternUploaded &&
|
!patternUploaded &&
|
||||||
!isUploading &&
|
!isUploading &&
|
||||||
!uploadedPesData
|
!uploadedPesData
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Handles both interactive (draggable/rotatable) and locked states
|
* Handles both interactive (draggable/rotatable) and locked states
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, memo, type RefObject } from "react";
|
import { useMemo, type RefObject } from "react";
|
||||||
import { Group, Transformer } from "react-konva";
|
import { Group, Transformer } from "react-konva";
|
||||||
import type Konva from "konva";
|
import type Konva from "konva";
|
||||||
import type { KonvaEventObject } from "konva/lib/Node";
|
import type { KonvaEventObject } from "konva/lib/Node";
|
||||||
|
|
@ -30,7 +30,7 @@ interface PatternLayerProps {
|
||||||
attachTransformer?: () => void;
|
attachTransformer?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PatternLayer = memo(function PatternLayer({
|
export function PatternLayer({
|
||||||
pesData,
|
pesData,
|
||||||
offset,
|
offset,
|
||||||
rotation = 0,
|
rotation = 0,
|
||||||
|
|
@ -143,4 +143,4 @@ export const PatternLayer = memo(function PatternLayer({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
|
||||||
395
src/components/ProgressMonitor.tsx
Normal file
395
src/components/ProgressMonitor.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
import { useRef, useEffect, useMemo } from "react";
|
||||||
|
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;
|
||||||
|
const currentBlockRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentBlockRef.current) {
|
||||||
|
currentBlockRef.current.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
calculateColorBlocks,
|
|
||||||
findCurrentBlockIndex,
|
|
||||||
} from "../../utils/colorBlockHelpers";
|
|
||||||
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";
|
|
||||||
|
|
||||||
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(
|
|
||||||
() => calculateColorBlocks(displayPattern),
|
|
||||||
[displayPattern],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine current color block based on current stitch
|
|
||||||
const currentStitch = sewingProgress?.currentStitch || 0;
|
|
||||||
const currentBlockIndex = findCurrentBlockIndex(colorBlocks, currentStitch);
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/**
|
|
||||||
* ProgressMonitor component barrel export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { ProgressMonitor } from "./ProgressMonitor";
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
504
src/components/WorkflowStepper.tsx
Normal file
504
src/components/WorkflowStepper.tsx
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
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
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
popoverRef.current &&
|
||||||
|
!popoverRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
// Check if click was on a step circle
|
||||||
|
const clickedStep = Object.values(stepRefs.current).find((ref) =>
|
||||||
|
ref?.contains(event.target as Node),
|
||||||
|
);
|
||||||
|
if (!clickedStep) {
|
||||||
|
setShowPopover(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showPopover) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [showPopover]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/**
|
|
||||||
* WorkflowStepper component barrel export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { WorkflowStepper } from "./WorkflowStepper";
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
export { usePatternValidation } from "./usePatternValidation";
|
|
||||||
export { usePatternRotationUpload } from "./usePatternRotationUpload";
|
|
||||||
export { useMachinePolling } from "./useMachinePolling";
|
|
||||||
export { useErrorPopoverState } from "./useErrorPopoverState";
|
|
||||||
export type {
|
|
||||||
UseMachinePollingOptions,
|
|
||||||
UseMachinePollingReturn,
|
|
||||||
} from "./useMachinePolling";
|
|
||||||
export type {
|
|
||||||
UseErrorPopoverStateOptions,
|
|
||||||
UseErrorPopoverStateReturn,
|
|
||||||
} from "./useErrorPopoverState";
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
||||||
import { useErrorPopoverState } from "./useErrorPopoverState";
|
|
||||||
|
|
||||||
describe("useErrorPopoverState", () => {
|
|
||||||
const hasError = (error: number | undefined) =>
|
|
||||||
error !== undefined && error !== 0;
|
|
||||||
|
|
||||||
it("should start with popover closed", () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError: undefined,
|
|
||||||
machineErrorMessage: null,
|
|
||||||
pyodideError: null,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
expect(result.current.wasManuallyDismissed).toBe(false);
|
|
||||||
expect(result.current.dismissedErrorCode).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should auto-open when machine error appears", () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ machineError }) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage: null,
|
|
||||||
pyodideError: null,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { machineError: undefined as number | undefined } },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
|
|
||||||
// Error appears
|
|
||||||
rerender({ machineError: 1 as number | undefined });
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should auto-open when machine error message appears", () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ machineErrorMessage }) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError: undefined,
|
|
||||||
machineErrorMessage,
|
|
||||||
pyodideError: null,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { machineErrorMessage: null as string | null } },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
|
|
||||||
rerender({ machineErrorMessage: "Error occurred" as string | null });
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should auto-open when pyodide error appears", () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ pyodideError }) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError: undefined,
|
|
||||||
machineErrorMessage: null,
|
|
||||||
pyodideError,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { pyodideError: null as string | null } },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
|
|
||||||
rerender({ pyodideError: "Pyodide error" as string | null });
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should auto-close when all errors are cleared", () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ machineError }) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage: null,
|
|
||||||
pyodideError: null,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { machineError: 1 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
|
|
||||||
// Clear error
|
|
||||||
rerender({ machineError: 0 });
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
expect(result.current.wasManuallyDismissed).toBe(false);
|
|
||||||
expect(result.current.dismissedErrorCode).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should track manual dismissal", async () => {
|
|
||||||
const { result } = renderHook(
|
|
||||||
({ machineError }) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage: null,
|
|
||||||
pyodideError: null,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { machineError: 1 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
|
|
||||||
// Manually dismiss
|
|
||||||
act(() => {
|
|
||||||
result.current.handleOpenChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
});
|
|
||||||
expect(result.current.wasManuallyDismissed).toBe(true);
|
|
||||||
expect(result.current.dismissedErrorCode).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not auto-reopen after manual dismissal", async () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ machineError }) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage: null,
|
|
||||||
pyodideError: null,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { machineError: 1 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Manually dismiss
|
|
||||||
act(() => {
|
|
||||||
result.current.handleOpenChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.wasManuallyDismissed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to reopen by changing error (but same error code)
|
|
||||||
rerender({ machineError: 1 });
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should auto-open for new error after manual dismissal and error clear", async () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ machineError }) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage: null,
|
|
||||||
pyodideError: null,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { machineError: 1 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Manually dismiss error 1
|
|
||||||
act(() => {
|
|
||||||
result.current.handleOpenChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.dismissedErrorCode).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all errors first (this resets wasManuallyDismissed)
|
|
||||||
rerender({ machineError: 0 });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.wasManuallyDismissed).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// New error appears (error 2)
|
|
||||||
rerender({ machineError: 2 });
|
|
||||||
|
|
||||||
// Should auto-open since manual dismissal was reset
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reset dismissal tracking when all errors clear", async () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ machineError }) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage: null,
|
|
||||||
pyodideError: null,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{ initialProps: { machineError: 1 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Manually dismiss
|
|
||||||
act(() => {
|
|
||||||
result.current.handleOpenChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.wasManuallyDismissed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear error
|
|
||||||
rerender({ machineError: 0 });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.wasManuallyDismissed).toBe(false);
|
|
||||||
});
|
|
||||||
expect(result.current.dismissedErrorCode).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle multiple error sources", () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage,
|
|
||||||
pyodideError,
|
|
||||||
}: {
|
|
||||||
machineError: number | undefined;
|
|
||||||
machineErrorMessage: string | null;
|
|
||||||
pyodideError: string | null;
|
|
||||||
}) =>
|
|
||||||
useErrorPopoverState({
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage,
|
|
||||||
pyodideError,
|
|
||||||
hasError,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
initialProps: {
|
|
||||||
machineError: undefined as number | undefined,
|
|
||||||
machineErrorMessage: null as string | null,
|
|
||||||
pyodideError: null as string | null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
|
|
||||||
// Machine error appears
|
|
||||||
rerender({
|
|
||||||
machineError: 1 as number | undefined,
|
|
||||||
machineErrorMessage: null as string | null,
|
|
||||||
pyodideError: null as string | null,
|
|
||||||
});
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
|
|
||||||
// Additional pyodide error
|
|
||||||
rerender({
|
|
||||||
machineError: 1 as number | undefined,
|
|
||||||
machineErrorMessage: null as string | null,
|
|
||||||
pyodideError: "Pyodide error" as string | null,
|
|
||||||
});
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
|
|
||||||
// Clear machine error but pyodide error remains
|
|
||||||
rerender({
|
|
||||||
machineError: 0 as number | undefined,
|
|
||||||
machineErrorMessage: null as string | null,
|
|
||||||
pyodideError: "Pyodide error" as string | null,
|
|
||||||
});
|
|
||||||
// Should stay open because pyodide error still exists
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
|
|
||||||
// Clear all errors
|
|
||||||
rerender({
|
|
||||||
machineError: 0 as number | undefined,
|
|
||||||
machineErrorMessage: null as string | null,
|
|
||||||
pyodideError: null as string | null,
|
|
||||||
});
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
/**
|
|
||||||
* useErrorPopoverState Hook
|
|
||||||
*
|
|
||||||
* Manages error popover state with sophisticated auto-open/close behavior.
|
|
||||||
* Automatically opens when new errors appear and closes when all errors are cleared.
|
|
||||||
* Tracks manual dismissal to prevent reopening for the same error.
|
|
||||||
*
|
|
||||||
* This hook is designed for multi-source error handling (e.g., machine errors,
|
|
||||||
* pyodide errors, error messages) and provides a consistent UX for error notification.
|
|
||||||
*
|
|
||||||
* @param options - Configuration options
|
|
||||||
* @param options.machineError - Current machine error code
|
|
||||||
* @param options.machineErrorMessage - Current machine error message
|
|
||||||
* @param options.pyodideError - Current Pyodide error message
|
|
||||||
* @param options.hasError - Function to check if an error code represents an error
|
|
||||||
* @returns Object containing popover state and control functions
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const { isOpen, handleOpenChange } = useErrorPopoverState({
|
|
||||||
* machineError,
|
|
||||||
* machineErrorMessage,
|
|
||||||
* pyodideError,
|
|
||||||
* hasError: (code) => code !== 0 && code !== undefined
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <Popover open={isOpen} onOpenChange={handleOpenChange}>
|
|
||||||
* <PopoverContent>{errorMessage}</PopoverContent>
|
|
||||||
* </Popover>
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { usePrevious } from "../utility/usePrevious";
|
|
||||||
|
|
||||||
export interface UseErrorPopoverStateOptions {
|
|
||||||
machineError: number | undefined;
|
|
||||||
machineErrorMessage: string | null;
|
|
||||||
pyodideError: string | null;
|
|
||||||
hasError: (error: number | undefined) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseErrorPopoverStateReturn {
|
|
||||||
isOpen: boolean;
|
|
||||||
handleOpenChange: (open: boolean) => void;
|
|
||||||
dismissedErrorCode: number | null;
|
|
||||||
wasManuallyDismissed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useErrorPopoverState(
|
|
||||||
options: UseErrorPopoverStateOptions,
|
|
||||||
): UseErrorPopoverStateReturn {
|
|
||||||
const { machineError, machineErrorMessage, pyodideError, hasError } = options;
|
|
||||||
|
|
||||||
// Internal state
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false);
|
|
||||||
|
|
||||||
// Track previous values for comparison
|
|
||||||
const prevMachineError = usePrevious(machineError);
|
|
||||||
const prevErrorMessage = usePrevious(machineErrorMessage);
|
|
||||||
const prevPyodideError = usePrevious(pyodideError);
|
|
||||||
|
|
||||||
// Auto-open/close logic
|
|
||||||
// Note: This effect intentionally calls setState to synchronize popover state with error state.
|
|
||||||
// This is a valid use case for setState in an effect as we're synchronizing external state
|
|
||||||
// (error codes) with internal UI state (popover visibility).
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
useEffect(() => {
|
|
||||||
// Check if there's any error now
|
|
||||||
const hasAnyError =
|
|
||||||
machineErrorMessage || pyodideError || hasError(machineError);
|
|
||||||
// Check if there was any error before
|
|
||||||
const hadAnyError =
|
|
||||||
prevErrorMessage || prevPyodideError || hasError(prevMachineError);
|
|
||||||
|
|
||||||
// Auto-open popover when new error appears (but not if user manually dismissed)
|
|
||||||
const isNewMachineError =
|
|
||||||
hasError(machineError) &&
|
|
||||||
machineError !== prevMachineError &&
|
|
||||||
machineError !== dismissedErrorCode;
|
|
||||||
const isNewErrorMessage =
|
|
||||||
machineErrorMessage && machineErrorMessage !== prevErrorMessage;
|
|
||||||
const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!wasManuallyDismissed &&
|
|
||||||
(isNewMachineError || isNewErrorMessage || isNewPyodideError)
|
|
||||||
) {
|
|
||||||
setIsOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-close popover when all errors are cleared
|
|
||||||
if (!hasAnyError && hadAnyError) {
|
|
||||||
setIsOpen(false);
|
|
||||||
setDismissedErrorCode(null); // Reset dismissed tracking
|
|
||||||
setWasManuallyDismissed(false); // Reset manual dismissal flag
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
machineError,
|
|
||||||
machineErrorMessage,
|
|
||||||
pyodideError,
|
|
||||||
dismissedErrorCode,
|
|
||||||
wasManuallyDismissed,
|
|
||||||
prevMachineError,
|
|
||||||
prevErrorMessage,
|
|
||||||
prevPyodideError,
|
|
||||||
hasError,
|
|
||||||
]);
|
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
// Handle manual popover dismiss
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
setIsOpen(open);
|
|
||||||
|
|
||||||
// If user manually closes it while any error is present, remember this to prevent reopening
|
|
||||||
if (
|
|
||||||
!open &&
|
|
||||||
(hasError(machineError) || machineErrorMessage || pyodideError)
|
|
||||||
) {
|
|
||||||
setWasManuallyDismissed(true);
|
|
||||||
// Also track the specific machine error code if present
|
|
||||||
if (hasError(machineError) && machineError !== undefined) {
|
|
||||||
setDismissedErrorCode(machineError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOpen,
|
|
||||||
handleOpenChange,
|
|
||||||
dismissedErrorCode,
|
|
||||||
wasManuallyDismissed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
||||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
||||||
import { useMachinePolling } from "./useMachinePolling";
|
|
||||||
import { MachineStatus } from "../../types/machine";
|
|
||||||
|
|
||||||
describe("useMachinePolling", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should start polling when startPolling is called", async () => {
|
|
||||||
const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useMachinePolling({
|
|
||||||
machineStatus: MachineStatus.IDLE,
|
|
||||||
patternInfo: null,
|
|
||||||
onStatusRefresh,
|
|
||||||
onProgressRefresh,
|
|
||||||
onServiceCountRefresh,
|
|
||||||
onPatternInfoRefresh,
|
|
||||||
shouldCheckResumablePattern: () => false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isPolling).toBe(false);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.startPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isPolling).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should stop polling when stopPolling is called", async () => {
|
|
||||||
const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useMachinePolling({
|
|
||||||
machineStatus: MachineStatus.IDLE,
|
|
||||||
patternInfo: null,
|
|
||||||
onStatusRefresh,
|
|
||||||
onProgressRefresh,
|
|
||||||
onServiceCountRefresh,
|
|
||||||
onPatternInfoRefresh,
|
|
||||||
shouldCheckResumablePattern: () => false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.startPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isPolling).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.stopPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isPolling).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should initialize polling correctly for SEWING state", async () => {
|
|
||||||
const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useMachinePolling({
|
|
||||||
machineStatus: MachineStatus.SEWING,
|
|
||||||
patternInfo: null,
|
|
||||||
onStatusRefresh,
|
|
||||||
onProgressRefresh,
|
|
||||||
onServiceCountRefresh,
|
|
||||||
onPatternInfoRefresh,
|
|
||||||
shouldCheckResumablePattern: () => false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.startPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isPolling).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.stopPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isPolling).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should initialize polling for different machine states", async () => {
|
|
||||||
const createMocks = () => ({
|
|
||||||
onStatusRefresh: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onProgressRefresh: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onServiceCountRefresh: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onPatternInfoRefresh: vi.fn().mockResolvedValue(undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test COLOR_CHANGE_WAIT state
|
|
||||||
const mocks1 = createMocks();
|
|
||||||
const { result: result1 } = renderHook(() =>
|
|
||||||
useMachinePolling({
|
|
||||||
machineStatus: MachineStatus.COLOR_CHANGE_WAIT,
|
|
||||||
patternInfo: null,
|
|
||||||
...mocks1,
|
|
||||||
shouldCheckResumablePattern: () => false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
act(() => {
|
|
||||||
result1.current.startPolling();
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result1.current.isPolling).toBe(true);
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
result1.current.stopPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test READY state
|
|
||||||
const mocks2 = createMocks();
|
|
||||||
const { result: result2 } = renderHook(() =>
|
|
||||||
useMachinePolling({
|
|
||||||
machineStatus: MachineStatus.IDLE,
|
|
||||||
patternInfo: null,
|
|
||||||
...mocks2,
|
|
||||||
shouldCheckResumablePattern: () => false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
act(() => {
|
|
||||||
result2.current.startPolling();
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result2.current.isPolling).toBe(true);
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
result2.current.stopPolling();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
/**
|
|
||||||
* useMachinePolling Hook
|
|
||||||
*
|
|
||||||
* Implements dynamic polling for machine status based on machine state.
|
|
||||||
* Uses adaptive polling intervals and conditional progress polling during sewing.
|
|
||||||
*
|
|
||||||
* Polling intervals:
|
|
||||||
* - 500ms for active states (SEWING, MASK_TRACING, SEWING_DATA_RECEIVE)
|
|
||||||
* - 1000ms for waiting states (COLOR_CHANGE_WAIT, MASK_TRACE_LOCK_WAIT, SEWING_WAIT)
|
|
||||||
* - 2000ms for idle/other states
|
|
||||||
*
|
|
||||||
* Additionally polls service count every 10 seconds.
|
|
||||||
*
|
|
||||||
* @param options - Configuration options
|
|
||||||
* @param options.machineStatus - Current machine status to determine polling interval
|
|
||||||
* @param options.patternInfo - Current pattern info for resumable pattern check
|
|
||||||
* @param options.onStatusRefresh - Callback to refresh machine status
|
|
||||||
* @param options.onProgressRefresh - Callback to refresh sewing progress
|
|
||||||
* @param options.onServiceCountRefresh - Callback to refresh service count
|
|
||||||
* @param options.onPatternInfoRefresh - Callback to refresh pattern info
|
|
||||||
* @param options.shouldCheckResumablePattern - Function to check if resumable pattern exists
|
|
||||||
* @returns Object containing start/stop functions and polling state
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const { startPolling, stopPolling, isPolling } = useMachinePolling({
|
|
||||||
* machineStatus,
|
|
||||||
* patternInfo,
|
|
||||||
* onStatusRefresh: async () => { ... },
|
|
||||||
* onProgressRefresh: async () => { ... },
|
|
||||||
* onServiceCountRefresh: async () => { ... },
|
|
||||||
* onPatternInfoRefresh: async () => { ... },
|
|
||||||
* shouldCheckResumablePattern: () => resumeAvailable
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* useEffect(() => {
|
|
||||||
* startPolling();
|
|
||||||
* return () => stopPolling();
|
|
||||||
* }, []);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
|
||||||
import { MachineStatus } from "../../types/machine";
|
|
||||||
import type { PatternInfo } from "../../types/machine";
|
|
||||||
|
|
||||||
export interface UseMachinePollingOptions {
|
|
||||||
machineStatus: MachineStatus;
|
|
||||||
patternInfo: PatternInfo | null;
|
|
||||||
onStatusRefresh: () => Promise<void>;
|
|
||||||
onProgressRefresh: () => Promise<void>;
|
|
||||||
onServiceCountRefresh: () => Promise<void>;
|
|
||||||
onPatternInfoRefresh: () => Promise<void>;
|
|
||||||
shouldCheckResumablePattern: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseMachinePollingReturn {
|
|
||||||
startPolling: () => void;
|
|
||||||
stopPolling: () => void;
|
|
||||||
isPolling: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMachinePolling(
|
|
||||||
options: UseMachinePollingOptions,
|
|
||||||
): UseMachinePollingReturn {
|
|
||||||
const {
|
|
||||||
machineStatus,
|
|
||||||
patternInfo,
|
|
||||||
onStatusRefresh,
|
|
||||||
onProgressRefresh,
|
|
||||||
onServiceCountRefresh,
|
|
||||||
onPatternInfoRefresh,
|
|
||||||
shouldCheckResumablePattern,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const [isPolling, setIsPolling] = useState(false);
|
|
||||||
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const serviceCountIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const pollFunctionRef = useRef<(() => Promise<void>) | undefined>(undefined);
|
|
||||||
|
|
||||||
// Store callbacks in refs to avoid unnecessary re-renders
|
|
||||||
const callbacksRef = useRef({
|
|
||||||
onStatusRefresh,
|
|
||||||
onProgressRefresh,
|
|
||||||
onServiceCountRefresh,
|
|
||||||
onPatternInfoRefresh,
|
|
||||||
shouldCheckResumablePattern,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update refs when callbacks change
|
|
||||||
useEffect(() => {
|
|
||||||
callbacksRef.current = {
|
|
||||||
onStatusRefresh,
|
|
||||||
onProgressRefresh,
|
|
||||||
onServiceCountRefresh,
|
|
||||||
onPatternInfoRefresh,
|
|
||||||
shouldCheckResumablePattern,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to determine polling interval based on machine status
|
|
||||||
const getPollInterval = useCallback((status: MachineStatus) => {
|
|
||||||
// Fast polling for active states
|
|
||||||
if (
|
|
||||||
status === MachineStatus.SEWING ||
|
|
||||||
status === MachineStatus.MASK_TRACING ||
|
|
||||||
status === MachineStatus.SEWING_DATA_RECEIVE
|
|
||||||
) {
|
|
||||||
return 500;
|
|
||||||
} else if (
|
|
||||||
status === MachineStatus.COLOR_CHANGE_WAIT ||
|
|
||||||
status === MachineStatus.MASK_TRACE_LOCK_WAIT ||
|
|
||||||
status === MachineStatus.SEWING_WAIT
|
|
||||||
) {
|
|
||||||
return 1000;
|
|
||||||
}
|
|
||||||
return 2000; // Default for idle states
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Main polling function
|
|
||||||
const poll = useCallback(async () => {
|
|
||||||
await callbacksRef.current.onStatusRefresh();
|
|
||||||
|
|
||||||
// Refresh progress during sewing
|
|
||||||
if (machineStatus === MachineStatus.SEWING) {
|
|
||||||
await callbacksRef.current.onProgressRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have a cached pattern and pattern info needs refreshing
|
|
||||||
// This follows the app's logic for resumable patterns
|
|
||||||
if (
|
|
||||||
callbacksRef.current.shouldCheckResumablePattern() &&
|
|
||||||
patternInfo?.totalStitches === 0
|
|
||||||
) {
|
|
||||||
await callbacksRef.current.onPatternInfoRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule next poll with updated interval
|
|
||||||
const newInterval = getPollInterval(machineStatus);
|
|
||||||
if (pollFunctionRef.current) {
|
|
||||||
pollTimeoutRef.current = setTimeout(pollFunctionRef.current, newInterval);
|
|
||||||
}
|
|
||||||
}, [machineStatus, patternInfo, getPollInterval]);
|
|
||||||
|
|
||||||
// Store poll function in ref for recursive setTimeout
|
|
||||||
useEffect(() => {
|
|
||||||
pollFunctionRef.current = poll;
|
|
||||||
}, [poll]);
|
|
||||||
|
|
||||||
const stopPolling = useCallback(() => {
|
|
||||||
if (pollTimeoutRef.current) {
|
|
||||||
clearTimeout(pollTimeoutRef.current);
|
|
||||||
pollTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serviceCountIntervalRef.current) {
|
|
||||||
clearInterval(serviceCountIntervalRef.current);
|
|
||||||
serviceCountIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsPolling(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startPolling = useCallback(() => {
|
|
||||||
// Stop any existing polling
|
|
||||||
stopPolling();
|
|
||||||
|
|
||||||
// Start main polling
|
|
||||||
const initialInterval = getPollInterval(machineStatus);
|
|
||||||
pollTimeoutRef.current = setTimeout(poll, initialInterval);
|
|
||||||
|
|
||||||
// Start service count polling (every 10 seconds)
|
|
||||||
serviceCountIntervalRef.current = setInterval(
|
|
||||||
callbacksRef.current.onServiceCountRefresh,
|
|
||||||
10000,
|
|
||||||
);
|
|
||||||
|
|
||||||
setIsPolling(true);
|
|
||||||
}, [machineStatus, poll, stopPolling, getPollInterval]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startPolling,
|
|
||||||
stopPolling,
|
|
||||||
isPolling,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
// Utility Hooks - Generic, reusable patterns
|
|
||||||
export * from "./utility";
|
|
||||||
|
|
||||||
// Domain Hooks - Business logic for embroidery/pattern operations
|
|
||||||
export * from "./domain";
|
|
||||||
|
|
||||||
// UI Hooks - Library/framework integration (Konva, etc.)
|
|
||||||
export * from "./ui";
|
|
||||||
|
|
||||||
// Platform Hooks - Electron/Pyodide specific functionality
|
|
||||||
export * from "./platform";
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { useFileUpload } from "./useFileUpload";
|
|
||||||
export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
|
|
||||||
export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener";
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
||||||
import { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
|
|
||||||
import type { BluetoothDevice } from "../../types/electron";
|
|
||||||
|
|
||||||
describe("useBluetoothDeviceListener", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset window.electronAPI before each test
|
|
||||||
delete (window as { electronAPI?: unknown }).electronAPI;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty state when Electron API is not available", () => {
|
|
||||||
const { result } = renderHook(() => useBluetoothDeviceListener());
|
|
||||||
|
|
||||||
expect(result.current.devices).toEqual([]);
|
|
||||||
expect(result.current.isScanning).toBe(false);
|
|
||||||
expect(result.current.isSupported).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return isSupported=true when Electron API is available", () => {
|
|
||||||
// Mock Electron API
|
|
||||||
(
|
|
||||||
window as unknown as {
|
|
||||||
electronAPI: { onBluetoothDeviceList: () => void };
|
|
||||||
}
|
|
||||||
).electronAPI = {
|
|
||||||
onBluetoothDeviceList: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useBluetoothDeviceListener());
|
|
||||||
|
|
||||||
expect(result.current.isSupported).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should register IPC listener when Electron API is available", () => {
|
|
||||||
const mockListener = vi.fn();
|
|
||||||
(
|
|
||||||
window as unknown as {
|
|
||||||
electronAPI: { onBluetoothDeviceList: typeof mockListener };
|
|
||||||
}
|
|
||||||
).electronAPI = {
|
|
||||||
onBluetoothDeviceList: mockListener,
|
|
||||||
};
|
|
||||||
|
|
||||||
renderHook(() => useBluetoothDeviceListener());
|
|
||||||
|
|
||||||
expect(mockListener).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockListener).toHaveBeenCalledWith(expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update devices when listener receives data", async () => {
|
|
||||||
let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null =
|
|
||||||
null;
|
|
||||||
|
|
||||||
const mockListener = vi.fn(
|
|
||||||
(callback: (devices: BluetoothDevice[]) => void) => {
|
|
||||||
deviceListCallback = callback;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
(
|
|
||||||
window as unknown as {
|
|
||||||
electronAPI: { onBluetoothDeviceList: typeof mockListener };
|
|
||||||
}
|
|
||||||
).electronAPI = {
|
|
||||||
onBluetoothDeviceList: mockListener,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useBluetoothDeviceListener());
|
|
||||||
|
|
||||||
expect(result.current.devices).toEqual([]);
|
|
||||||
|
|
||||||
// Simulate device list update
|
|
||||||
const mockDevices: BluetoothDevice[] = [
|
|
||||||
{ deviceId: "device1", deviceName: "Device 1" },
|
|
||||||
{ deviceId: "device2", deviceName: "Device 2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Trigger the callback
|
|
||||||
act(() => {
|
|
||||||
deviceListCallback!(mockDevices);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.devices).toEqual(mockDevices);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set isScanning=true when empty device list received", async () => {
|
|
||||||
let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null =
|
|
||||||
null;
|
|
||||||
|
|
||||||
const mockListener = vi.fn(
|
|
||||||
(callback: (devices: BluetoothDevice[]) => void) => {
|
|
||||||
deviceListCallback = callback;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
(
|
|
||||||
window as unknown as {
|
|
||||||
electronAPI: { onBluetoothDeviceList: typeof mockListener };
|
|
||||||
}
|
|
||||||
).electronAPI = {
|
|
||||||
onBluetoothDeviceList: mockListener,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useBluetoothDeviceListener());
|
|
||||||
|
|
||||||
// Simulate empty device list (scanning in progress)
|
|
||||||
act(() => {
|
|
||||||
deviceListCallback!([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isScanning).toBe(true);
|
|
||||||
});
|
|
||||||
expect(result.current.devices).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set isScanning=false when devices are received", async () => {
|
|
||||||
let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null =
|
|
||||||
null;
|
|
||||||
|
|
||||||
const mockListener = vi.fn(
|
|
||||||
(callback: (devices: BluetoothDevice[]) => void) => {
|
|
||||||
deviceListCallback = callback;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
(
|
|
||||||
window as unknown as {
|
|
||||||
electronAPI: { onBluetoothDeviceList: typeof mockListener };
|
|
||||||
}
|
|
||||||
).electronAPI = {
|
|
||||||
onBluetoothDeviceList: mockListener,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useBluetoothDeviceListener());
|
|
||||||
|
|
||||||
// First update: empty list (scanning)
|
|
||||||
act(() => {
|
|
||||||
deviceListCallback!([]);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isScanning).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second update: devices found (stop scanning indicator)
|
|
||||||
const mockDevices: BluetoothDevice[] = [
|
|
||||||
{ deviceId: "device1", deviceName: "Device 1" },
|
|
||||||
];
|
|
||||||
act(() => {
|
|
||||||
deviceListCallback!(mockDevices);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isScanning).toBe(false);
|
|
||||||
});
|
|
||||||
expect(result.current.devices).toEqual(mockDevices);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call optional callback when devices change", async () => {
|
|
||||||
let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null =
|
|
||||||
null;
|
|
||||||
|
|
||||||
const mockListener = vi.fn(
|
|
||||||
(callback: (devices: BluetoothDevice[]) => void) => {
|
|
||||||
deviceListCallback = callback;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
(
|
|
||||||
window as unknown as {
|
|
||||||
electronAPI: { onBluetoothDeviceList: typeof mockListener };
|
|
||||||
}
|
|
||||||
).electronAPI = {
|
|
||||||
onBluetoothDeviceList: mockListener,
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDevicesChanged = vi.fn();
|
|
||||||
renderHook(() => useBluetoothDeviceListener(onDevicesChanged));
|
|
||||||
|
|
||||||
const mockDevices: BluetoothDevice[] = [
|
|
||||||
{ deviceId: "device1", deviceName: "Device 1" },
|
|
||||||
];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
deviceListCallback!(mockDevices);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(onDevicesChanged).toHaveBeenCalledWith(mockDevices);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
/**
|
|
||||||
* useBluetoothDeviceListener Hook
|
|
||||||
*
|
|
||||||
* Listens for Bluetooth device discovery events from Electron IPC.
|
|
||||||
* Automatically manages device list state and provides platform detection.
|
|
||||||
*
|
|
||||||
* This hook is Electron-specific and will gracefully handle browser environments
|
|
||||||
* by returning empty state.
|
|
||||||
*
|
|
||||||
* @param onDevicesChanged - Optional callback when device list changes
|
|
||||||
* @returns Object containing devices array, scanning state, and platform support flag
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const { devices, isScanning, isSupported } = useBluetoothDeviceListener(
|
|
||||||
* (devices) => {
|
|
||||||
* if (devices.length > 0) {
|
|
||||||
* console.log('Devices found:', devices);
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* if (!isSupported) {
|
|
||||||
* return <div>Bluetooth pairing only available in Electron app</div>;
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <div>
|
|
||||||
* {isScanning && <p>Scanning...</p>}
|
|
||||||
* {devices.map(device => <div key={device.id}>{device.name}</div>)}
|
|
||||||
* </div>
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
|
||||||
import type { BluetoothDevice } from "../../types/electron";
|
|
||||||
|
|
||||||
export interface UseBluetoothDeviceListenerReturn {
|
|
||||||
devices: BluetoothDevice[];
|
|
||||||
isScanning: boolean;
|
|
||||||
isSupported: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBluetoothDeviceListener(
|
|
||||||
onDevicesChanged?: (devices: BluetoothDevice[]) => void,
|
|
||||||
): UseBluetoothDeviceListenerReturn {
|
|
||||||
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
|
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
|
||||||
|
|
||||||
// Store callback in ref to avoid re-registering listener
|
|
||||||
const callbackRef = useRef(onDevicesChanged);
|
|
||||||
|
|
||||||
// Update ref when callback changes
|
|
||||||
useEffect(() => {
|
|
||||||
callbackRef.current = onDevicesChanged;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if Electron API is available
|
|
||||||
const isSupported =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
!!window.electronAPI?.onBluetoothDeviceList;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only set up listener in Electron
|
|
||||||
if (!isSupported) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeviceList = (deviceList: BluetoothDevice[]) => {
|
|
||||||
setDevices(deviceList);
|
|
||||||
|
|
||||||
// Start scanning when first update received
|
|
||||||
if (deviceList.length === 0) {
|
|
||||||
setIsScanning(true);
|
|
||||||
} else {
|
|
||||||
// Stop showing scanning state once we have devices
|
|
||||||
setIsScanning(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call optional callback using ref to get latest version
|
|
||||||
callbackRef.current?.(deviceList);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register listener only once
|
|
||||||
window.electronAPI!.onBluetoothDeviceList(handleDeviceList);
|
|
||||||
|
|
||||||
// Note: Electron IPC listeners are typically not cleaned up individually
|
|
||||||
// as they're meant to persist. If cleanup is needed, the Electron main
|
|
||||||
// process should handle it.
|
|
||||||
}, [isSupported]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
devices,
|
|
||||||
isScanning,
|
|
||||||
isSupported,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { useCanvasViewport } from "./useCanvasViewport";
|
|
||||||
export { usePatternTransform } from "./usePatternTransform";
|
|
||||||
|
|
@ -7,10 +7,10 @@
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, type RefObject } from "react";
|
import { useState, useEffect, useCallback, type RefObject } from "react";
|
||||||
import type Konva from "konva";
|
import type Konva from "konva";
|
||||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
import type { MachineInfo } from "../../types/machine";
|
import type { MachineInfo } from "../types/machine";
|
||||||
import { calculateInitialScale } from "../../utils/konvaRenderers";
|
import { calculateInitialScale } from "../utils/konvaRenderers";
|
||||||
import { calculateZoomToPoint } from "../../components/PatternCanvas/patternCanvasHelpers";
|
import { calculateZoomToPoint } from "../components/PatternCanvas/patternCanvasHelpers";
|
||||||
|
|
||||||
interface UseCanvasViewportOptions {
|
interface UseCanvasViewportOptions {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
|
@ -2,8 +2,8 @@ import { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
convertPesToPen,
|
convertPesToPen,
|
||||||
type PesPatternData,
|
type PesPatternData,
|
||||||
} from "../../formats/import/pesImporter";
|
} from "../formats/import/pesImporter";
|
||||||
import type { IFileService } from "../../platform/interfaces/IFileService";
|
import type { IFileService } from "../platform/interfaces/IFileService";
|
||||||
|
|
||||||
export interface UseFileUploadParams {
|
export interface UseFileUploadParams {
|
||||||
fileService: IFileService;
|
fileService: IFileService;
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
import { transformStitchesRotation } from "../../utils/rotationUtils";
|
import { transformStitchesRotation } from "../utils/rotationUtils";
|
||||||
import { encodeStitchesToPen } from "../../formats/pen/encoder";
|
import { encodeStitchesToPen } from "../formats/pen/encoder";
|
||||||
import { decodePenData } from "../../formats/pen/decoder";
|
import { decodePenData } from "../formats/pen/decoder";
|
||||||
import {
|
import {
|
||||||
calculatePatternCenter,
|
calculatePatternCenter,
|
||||||
calculateBoundsFromDecodedStitches,
|
calculateBoundsFromDecodedStitches,
|
||||||
} from "../../components/PatternCanvas/patternCanvasHelpers";
|
} from "../components/PatternCanvas/patternCanvasHelpers";
|
||||||
|
|
||||||
export interface UsePatternRotationUploadParams {
|
export interface UsePatternRotationUploadParams {
|
||||||
uploadPattern: (
|
uploadPattern: (
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type Konva from "konva";
|
import type Konva from "konva";
|
||||||
import type { KonvaEventObject } from "konva/lib/Node";
|
import type { KonvaEventObject } from "konva/lib/Node";
|
||||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
|
|
||||||
interface UsePatternTransformOptions {
|
interface UsePatternTransformOptions {
|
||||||
pesData: PesPatternData | null;
|
pesData: PesPatternData | null;
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { PesPatternData } from "../../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
import type { MachineInfo } from "../../types/machine";
|
import type { MachineInfo } from "../types/machine";
|
||||||
import { calculateRotatedBounds } from "../../utils/rotationUtils";
|
import { calculateRotatedBounds } from "../utils/rotationUtils";
|
||||||
import { calculatePatternCenter } from "../../components/PatternCanvas/patternCanvasHelpers";
|
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
|
||||||
|
|
||||||
export interface PatternBoundsCheckResult {
|
export interface PatternBoundsCheckResult {
|
||||||
fits: boolean;
|
fits: boolean;
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export { usePrevious } from "./usePrevious";
|
|
||||||
export { useAutoScroll } from "./useAutoScroll";
|
|
||||||
export { useClickOutside } from "./useClickOutside";
|
|
||||||
export type { UseAutoScrollOptions } from "./useAutoScroll";
|
|
||||||
export type { UseClickOutsideOptions } from "./useClickOutside";
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { renderHook } from "@testing-library/react";
|
|
||||||
import { useAutoScroll } from "./useAutoScroll";
|
|
||||||
|
|
||||||
describe("useAutoScroll", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Mock scrollIntoView
|
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return a ref object", () => {
|
|
||||||
const { result } = renderHook(() => useAutoScroll(0));
|
|
||||||
expect(result.current).toHaveProperty("current");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call scrollIntoView when dependency changes", () => {
|
|
||||||
const mockElement = document.createElement("div");
|
|
||||||
const scrollIntoViewMock = vi.fn();
|
|
||||||
mockElement.scrollIntoView = scrollIntoViewMock;
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(({ dep }) => useAutoScroll(dep), {
|
|
||||||
initialProps: { dep: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach mock element to ref
|
|
||||||
(result.current as { current: HTMLElement }).current = mockElement;
|
|
||||||
|
|
||||||
// Change dependency to trigger effect
|
|
||||||
rerender({ dep: 1 });
|
|
||||||
|
|
||||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "nearest",
|
|
||||||
inline: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use custom scroll options", () => {
|
|
||||||
const mockElement = document.createElement("div");
|
|
||||||
const scrollIntoViewMock = vi.fn();
|
|
||||||
mockElement.scrollIntoView = scrollIntoViewMock;
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ dep }) =>
|
|
||||||
useAutoScroll(dep, {
|
|
||||||
behavior: "auto",
|
|
||||||
block: "start",
|
|
||||||
inline: "center",
|
|
||||||
}),
|
|
||||||
{ initialProps: { dep: 0 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
(result.current as { current: HTMLElement }).current = mockElement;
|
|
||||||
rerender({ dep: 1 });
|
|
||||||
|
|
||||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
|
||||||
behavior: "auto",
|
|
||||||
block: "start",
|
|
||||||
inline: "center",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not call scrollIntoView if ref is not attached", () => {
|
|
||||||
const { rerender } = renderHook(({ dep }) => useAutoScroll(dep), {
|
|
||||||
initialProps: { dep: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change dependency without attaching ref
|
|
||||||
rerender({ dep: 1 });
|
|
||||||
|
|
||||||
// Should not throw or cause errors
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
/**
|
|
||||||
* useAutoScroll Hook
|
|
||||||
*
|
|
||||||
* Automatically scrolls an element into view when a dependency changes.
|
|
||||||
* Useful for keeping the current item visible in scrollable lists.
|
|
||||||
*
|
|
||||||
* @param dependency - The value to watch for changes (e.g., current index)
|
|
||||||
* @param options - Scroll behavior options
|
|
||||||
* @returns RefObject to attach to the element that should be scrolled into view
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const currentItemRef = useAutoScroll(currentIndex, {
|
|
||||||
* behavior: "smooth",
|
|
||||||
* block: "nearest"
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <div ref={isCurrent ? currentItemRef : null}>
|
|
||||||
* Current Item
|
|
||||||
* </div>
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useMemo, type RefObject } from "react";
|
|
||||||
|
|
||||||
export interface UseAutoScrollOptions {
|
|
||||||
behavior?: ScrollBehavior;
|
|
||||||
block?: ScrollLogicalPosition;
|
|
||||||
inline?: ScrollLogicalPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAutoScroll<T extends HTMLElement = HTMLElement>(
|
|
||||||
dependency: unknown,
|
|
||||||
options?: UseAutoScrollOptions,
|
|
||||||
): RefObject<T | null> {
|
|
||||||
const ref = useRef<T>(null);
|
|
||||||
|
|
||||||
// Stabilize options to avoid unnecessary re-renders when passed as inline object
|
|
||||||
const stableOptions = useMemo(
|
|
||||||
() => ({
|
|
||||||
behavior: options?.behavior || "smooth",
|
|
||||||
block: options?.block || "nearest",
|
|
||||||
inline: options?.inline,
|
|
||||||
}),
|
|
||||||
[options?.behavior, options?.block, options?.inline],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ref.current) {
|
|
||||||
ref.current.scrollIntoView(stableOptions);
|
|
||||||
}
|
|
||||||
}, [dependency, stableOptions]);
|
|
||||||
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { renderHook } from "@testing-library/react";
|
|
||||||
import { useClickOutside } from "./useClickOutside";
|
|
||||||
import { useRef, type RefObject } from "react";
|
|
||||||
|
|
||||||
describe("useClickOutside", () => {
|
|
||||||
it("should call handler when clicking outside element", () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const { result } = renderHook(() => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useClickOutside(ref, handler);
|
|
||||||
return ref;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create and attach mock element
|
|
||||||
const element = document.createElement("div");
|
|
||||||
document.body.appendChild(element);
|
|
||||||
(result.current as { current: HTMLDivElement }).current = element;
|
|
||||||
|
|
||||||
// Click outside
|
|
||||||
const outsideElement = document.createElement("div");
|
|
||||||
document.body.appendChild(outsideElement);
|
|
||||||
outsideElement.dispatchEvent(
|
|
||||||
new MouseEvent("mousedown", { bubbles: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
document.body.removeChild(element);
|
|
||||||
document.body.removeChild(outsideElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not call handler when clicking inside element", () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const { result } = renderHook(() => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useClickOutside(ref, handler);
|
|
||||||
return ref;
|
|
||||||
});
|
|
||||||
|
|
||||||
const element = document.createElement("div");
|
|
||||||
document.body.appendChild(element);
|
|
||||||
(result.current as { current: HTMLDivElement }).current = element;
|
|
||||||
|
|
||||||
// Click inside
|
|
||||||
element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
document.body.removeChild(element);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should respect enabled option", () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ enabled }) => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useClickOutside(ref, handler, { enabled });
|
|
||||||
return ref;
|
|
||||||
},
|
|
||||||
{ initialProps: { enabled: false } },
|
|
||||||
);
|
|
||||||
|
|
||||||
const element = document.createElement("div");
|
|
||||||
document.body.appendChild(element);
|
|
||||||
(result.current as { current: HTMLDivElement }).current = element;
|
|
||||||
|
|
||||||
// Click outside while disabled
|
|
||||||
const outsideElement = document.createElement("div");
|
|
||||||
document.body.appendChild(outsideElement);
|
|
||||||
outsideElement.dispatchEvent(
|
|
||||||
new MouseEvent("mousedown", { bubbles: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Enable and click outside again
|
|
||||||
rerender({ enabled: true });
|
|
||||||
outsideElement.dispatchEvent(
|
|
||||||
new MouseEvent("mousedown", { bubbles: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
document.body.removeChild(element);
|
|
||||||
document.body.removeChild(outsideElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not call handler when clicking excluded refs", () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const { result } = renderHook(() => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const excludeRef = useRef<HTMLElement>(null);
|
|
||||||
useClickOutside(ref, handler, {
|
|
||||||
excludeRefs: [excludeRef as unknown as RefObject<HTMLElement>],
|
|
||||||
});
|
|
||||||
return { ref, excludeRef };
|
|
||||||
});
|
|
||||||
|
|
||||||
const element = document.createElement("div");
|
|
||||||
const excludedElement = document.createElement("button");
|
|
||||||
document.body.appendChild(element);
|
|
||||||
document.body.appendChild(excludedElement);
|
|
||||||
|
|
||||||
(result.current.ref as { current: HTMLDivElement }).current = element;
|
|
||||||
(result.current.excludeRef as { current: HTMLElement }).current =
|
|
||||||
excludedElement;
|
|
||||||
|
|
||||||
// Click on excluded element
|
|
||||||
excludedElement.dispatchEvent(
|
|
||||||
new MouseEvent("mousedown", { bubbles: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
document.body.removeChild(element);
|
|
||||||
document.body.removeChild(excludedElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle object of refs (WorkflowStepper pattern)", () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const { result } = renderHook(() => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
|
|
||||||
useClickOutside(ref, handler, { excludeRefs: [stepRefs] });
|
|
||||||
return { ref, stepRefs };
|
|
||||||
});
|
|
||||||
|
|
||||||
const element = document.createElement("div");
|
|
||||||
const step1 = document.createElement("div");
|
|
||||||
const step2 = document.createElement("div");
|
|
||||||
document.body.appendChild(element);
|
|
||||||
document.body.appendChild(step1);
|
|
||||||
document.body.appendChild(step2);
|
|
||||||
|
|
||||||
(result.current.ref as { current: HTMLDivElement }).current = element;
|
|
||||||
(
|
|
||||||
result.current.stepRefs as {
|
|
||||||
current: { [key: number]: HTMLDivElement | null };
|
|
||||||
}
|
|
||||||
).current = {
|
|
||||||
1: step1,
|
|
||||||
2: step2,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Click on step1 (excluded)
|
|
||||||
step1.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Click on step2 (excluded)
|
|
||||||
step2.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
document.body.removeChild(element);
|
|
||||||
document.body.removeChild(step1);
|
|
||||||
document.body.removeChild(step2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
/**
|
|
||||||
* useClickOutside Hook
|
|
||||||
*
|
|
||||||
* Detects clicks outside a referenced element and executes a handler function.
|
|
||||||
* Useful for closing dropdown menus, popovers, modals, and other overlay UI elements.
|
|
||||||
*
|
|
||||||
* @param ref - Reference to the element to monitor for outside clicks
|
|
||||||
* @param handler - Callback function to execute when outside click is detected
|
|
||||||
* @param options - Configuration options
|
|
||||||
* @param options.enabled - Whether the listener is active (default: true)
|
|
||||||
* @param options.excludeRefs - Array of refs that should not trigger the handler when clicked
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
* const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
*
|
|
||||||
* useClickOutside(
|
|
||||||
* dropdownRef,
|
|
||||||
* () => setIsOpen(false),
|
|
||||||
* {
|
|
||||||
* enabled: isOpen,
|
|
||||||
* excludeRefs: [buttonRef] // Don't close when clicking the button
|
|
||||||
* }
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <>
|
|
||||||
* <button ref={buttonRef}>Toggle</button>
|
|
||||||
* {isOpen && <div ref={dropdownRef}>Content</div>}
|
|
||||||
* </>
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, type RefObject } from "react";
|
|
||||||
|
|
||||||
export interface UseClickOutsideOptions {
|
|
||||||
enabled?: boolean;
|
|
||||||
excludeRefs?: (
|
|
||||||
| RefObject<HTMLElement>
|
|
||||||
| { current: Record<string, HTMLElement | null> }
|
|
||||||
)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useClickOutside<T extends HTMLElement = HTMLElement>(
|
|
||||||
ref: RefObject<T | null>,
|
|
||||||
handler: (event: MouseEvent) => void,
|
|
||||||
options?: UseClickOutsideOptions,
|
|
||||||
): void {
|
|
||||||
const { enabled = true, excludeRefs = [] } = options || {};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
// Check if click is outside the main ref
|
|
||||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
|
||||||
// Check if click is on any excluded refs
|
|
||||||
const clickedExcluded = excludeRefs.some((excludeRef) => {
|
|
||||||
if (!excludeRef.current) return false;
|
|
||||||
|
|
||||||
// Handle object of refs (e.g., { [key: number]: HTMLElement | null })
|
|
||||||
if (
|
|
||||||
typeof excludeRef.current === "object" &&
|
|
||||||
!("nodeType" in excludeRef.current)
|
|
||||||
) {
|
|
||||||
return Object.values(excludeRef.current).some((element) =>
|
|
||||||
element?.contains(event.target as Node),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle single ref
|
|
||||||
return (excludeRef.current as HTMLElement).contains(
|
|
||||||
event.target as Node,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only call handler if click was not on excluded elements
|
|
||||||
if (!clickedExcluded) {
|
|
||||||
handler(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [ref, handler, enabled, excludeRefs]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { renderHook } from "@testing-library/react";
|
|
||||||
import { usePrevious } from "./usePrevious";
|
|
||||||
|
|
||||||
describe("usePrevious", () => {
|
|
||||||
it("should return undefined on initial render", () => {
|
|
||||||
const { result } = renderHook(() => usePrevious(5));
|
|
||||||
expect(result.current).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return previous value after update", () => {
|
|
||||||
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
|
|
||||||
initialProps: { value: 5 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current).toBeUndefined();
|
|
||||||
|
|
||||||
rerender({ value: 10 });
|
|
||||||
expect(result.current).toBe(5);
|
|
||||||
|
|
||||||
rerender({ value: 15 });
|
|
||||||
expect(result.current).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle different types of values", () => {
|
|
||||||
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
|
|
||||||
initialProps: { value: "hello" as string | number | null },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current).toBeUndefined();
|
|
||||||
|
|
||||||
rerender({ value: 42 });
|
|
||||||
expect(result.current).toBe("hello");
|
|
||||||
|
|
||||||
rerender({ value: null });
|
|
||||||
expect(result.current).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle object references", () => {
|
|
||||||
const obj1 = { name: "first" };
|
|
||||||
const obj2 = { name: "second" };
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
|
|
||||||
initialProps: { value: obj1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current).toBeUndefined();
|
|
||||||
|
|
||||||
rerender({ value: obj2 });
|
|
||||||
expect(result.current).toBe(obj1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
/**
|
|
||||||
* Store Events
|
|
||||||
*
|
|
||||||
* Zustand-based event store for cross-store communication without tight coupling.
|
|
||||||
* Uses Zustand's built-in subscription system to emit and react to events.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
interface EventState {
|
|
||||||
// Event counters - incrementing these triggers subscriptions
|
|
||||||
patternDeletedCount: number;
|
|
||||||
|
|
||||||
// Actions to emit events
|
|
||||||
emitPatternDeleted: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event store using Zustand for cross-store communication.
|
|
||||||
* Stores can emit events by calling actions, and subscribe to events using Zustand's subscribe.
|
|
||||||
*/
|
|
||||||
export const useEventStore = create<EventState>((set) => ({
|
|
||||||
patternDeletedCount: 0,
|
|
||||||
|
|
||||||
emitPatternDeleted: () => {
|
|
||||||
set((state) => ({ patternDeletedCount: state.patternDeletedCount + 1 }));
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to the pattern deleted event.
|
|
||||||
*
|
|
||||||
* The subscription remains active until the returned unsubscribe function is called.
|
|
||||||
* If the unsubscribe function is not called, the listener will persist for the
|
|
||||||
* lifetime of the event store (typically the lifetime of the application).
|
|
||||||
*
|
|
||||||
* Call the returned unsubscribe function when the listener is no longer needed,
|
|
||||||
* especially for short-lived components or non-module-level subscriptions.
|
|
||||||
*
|
|
||||||
* @param callback - Function to call when the event is emitted.
|
|
||||||
* @returns Unsubscribe function that removes the listener when invoked.
|
|
||||||
*/
|
|
||||||
export const onPatternDeleted = (callback: () => void): (() => void) => {
|
|
||||||
let prevCount = useEventStore.getState().patternDeletedCount;
|
|
||||||
|
|
||||||
return useEventStore.subscribe((state) => {
|
|
||||||
if (state.patternDeletedCount !== prevCount) {
|
|
||||||
prevCount = state.patternDeletedCount;
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
import { uuidToString } from "../services/PatternCacheService";
|
import { uuidToString } from "../services/PatternCacheService";
|
||||||
import { onPatternDeleted } from "./storeEvents";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Machine Cache Store
|
* Machine Cache Store
|
||||||
|
|
@ -193,18 +192,3 @@ export const useMachineCacheStore = create<MachineCacheState>((set, get) => ({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Subscribe to pattern deleted event.
|
|
||||||
// This subscription is intended to persist for the lifetime of the application,
|
|
||||||
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
|
|
||||||
// not stored or called.
|
|
||||||
onPatternDeleted(() => {
|
|
||||||
try {
|
|
||||||
useMachineCacheStore.getState().clearResumeState();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[MachineCacheStore] Failed to clear resume state on pattern deleted event:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { SewingMachineError } from "../utils/errorCodeHelpers";
|
||||||
import { uuidToString } from "../services/PatternCacheService";
|
import { uuidToString } from "../services/PatternCacheService";
|
||||||
import { createStorageService } from "../platform";
|
import { createStorageService } from "../platform";
|
||||||
import type { IStorageService } from "../platform/interfaces/IStorageService";
|
import type { IStorageService } from "../platform/interfaces/IStorageService";
|
||||||
import { useEventStore } from "./storeEvents";
|
import { usePatternStore } from "./usePatternStore";
|
||||||
|
|
||||||
interface MachineState {
|
interface MachineState {
|
||||||
// Service instances
|
// Service instances
|
||||||
|
|
@ -291,8 +291,16 @@ export const useMachineStore = create<MachineState>((set, get) => ({
|
||||||
sewingProgress: null,
|
sewingProgress: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit pattern deleted event for other stores to react
|
// Clear uploaded pattern data in pattern store
|
||||||
useEventStore.getState().emitPatternDeleted();
|
usePatternStore.getState().clearUploadedPattern();
|
||||||
|
|
||||||
|
// Clear upload state in upload store
|
||||||
|
const { useMachineUploadStore } = await import("./useMachineUploadStore");
|
||||||
|
useMachineUploadStore.getState().reset();
|
||||||
|
|
||||||
|
// Clear resume state in cache store
|
||||||
|
const { useMachineCacheStore } = await import("./useMachineCacheStore");
|
||||||
|
useMachineCacheStore.getState().clearResumeState();
|
||||||
|
|
||||||
await refreshStatus();
|
await refreshStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
import { uuidToString } from "../services/PatternCacheService";
|
import { uuidToString } from "../services/PatternCacheService";
|
||||||
import { onPatternDeleted } from "./storeEvents";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Machine Upload Store
|
* Machine Upload Store
|
||||||
|
|
@ -127,18 +126,3 @@ export const useMachineUploadStore = create<MachineUploadState>((set) => ({
|
||||||
set({ uploadProgress: 0, isUploading: false });
|
set({ uploadProgress: 0, isUploading: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Subscribe to pattern deleted event.
|
|
||||||
// This subscription is intended to persist for the lifetime of the application,
|
|
||||||
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
|
|
||||||
// not stored or called.
|
|
||||||
onPatternDeleted(() => {
|
|
||||||
try {
|
|
||||||
useMachineUploadStore.getState().reset();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[MachineUploadStore] Failed to reset on pattern deleted event:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||||
import { onPatternDeleted } from "./storeEvents";
|
|
||||||
|
|
||||||
interface PatternState {
|
interface PatternState {
|
||||||
// Original pattern (pre-upload)
|
// Original pattern (pre-upload)
|
||||||
|
|
@ -122,18 +121,3 @@ export const useUploadedPatternOffset = () =>
|
||||||
usePatternStore((state) => state.uploadedPatternOffset);
|
usePatternStore((state) => state.uploadedPatternOffset);
|
||||||
export const usePatternRotation = () =>
|
export const usePatternRotation = () =>
|
||||||
usePatternStore((state) => state.patternRotation);
|
usePatternStore((state) => state.patternRotation);
|
||||||
|
|
||||||
// Subscribe to pattern deleted event.
|
|
||||||
// This subscription is intended to persist for the lifetime of the application,
|
|
||||||
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
|
|
||||||
// not stored or called.
|
|
||||||
onPatternDeleted(() => {
|
|
||||||
try {
|
|
||||||
usePatternStore.getState().clearUploadedPattern();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[PatternStore] Failed to clear uploaded pattern on pattern deleted event:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
calculateColorBlocks,
|
|
||||||
findCurrentBlockIndex,
|
|
||||||
} from "./colorBlockHelpers";
|
|
||||||
import type { PesPatternData } from "../formats/import/client";
|
|
||||||
|
|
||||||
describe("colorBlockHelpers", () => {
|
|
||||||
describe("calculateColorBlocks", () => {
|
|
||||||
it("should return empty array when displayPattern is null", () => {
|
|
||||||
const result = calculateColorBlocks(null);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty array when penStitches is undefined", () => {
|
|
||||||
const pattern = {
|
|
||||||
penStitches: undefined,
|
|
||||||
} as unknown as PesPatternData;
|
|
||||||
|
|
||||||
const result = calculateColorBlocks(pattern);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should calculate color blocks from PEN data", () => {
|
|
||||||
const pattern: Partial<PesPatternData> = {
|
|
||||||
threads: [
|
|
||||||
{
|
|
||||||
color: 1,
|
|
||||||
hex: "#FF0000",
|
|
||||||
brand: "Brother",
|
|
||||||
catalogNumber: "001",
|
|
||||||
description: "Red",
|
|
||||||
chart: "A",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 2,
|
|
||||||
hex: "#00FF00",
|
|
||||||
brand: "Brother",
|
|
||||||
catalogNumber: "002",
|
|
||||||
description: "Green",
|
|
||||||
chart: "B",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
penStitches: {
|
|
||||||
colorBlocks: [
|
|
||||||
{
|
|
||||||
startStitchIndex: 0,
|
|
||||||
endStitchIndex: 100,
|
|
||||||
colorIndex: 0,
|
|
||||||
startStitch: 0,
|
|
||||||
endStitch: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
startStitchIndex: 100,
|
|
||||||
endStitchIndex: 250,
|
|
||||||
colorIndex: 1,
|
|
||||||
startStitch: 100,
|
|
||||||
endStitch: 250,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stitches: [],
|
|
||||||
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = calculateColorBlocks(pattern as PesPatternData);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0]).toEqual({
|
|
||||||
colorIndex: 0,
|
|
||||||
threadHex: "#FF0000",
|
|
||||||
threadCatalogNumber: "001",
|
|
||||||
threadBrand: "Brother",
|
|
||||||
threadDescription: "Red",
|
|
||||||
threadChart: "A",
|
|
||||||
startStitch: 0,
|
|
||||||
endStitch: 100,
|
|
||||||
stitchCount: 100,
|
|
||||||
});
|
|
||||||
expect(result[1]).toEqual({
|
|
||||||
colorIndex: 1,
|
|
||||||
threadHex: "#00FF00",
|
|
||||||
threadCatalogNumber: "002",
|
|
||||||
threadBrand: "Brother",
|
|
||||||
threadDescription: "Green",
|
|
||||||
threadChart: "B",
|
|
||||||
startStitch: 100,
|
|
||||||
endStitch: 250,
|
|
||||||
stitchCount: 150,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use fallback values when thread data is missing", () => {
|
|
||||||
const pattern: Partial<PesPatternData> = {
|
|
||||||
threads: [],
|
|
||||||
penStitches: {
|
|
||||||
colorBlocks: [
|
|
||||||
{
|
|
||||||
startStitchIndex: 0,
|
|
||||||
endStitchIndex: 50,
|
|
||||||
colorIndex: 0,
|
|
||||||
startStitch: 0,
|
|
||||||
endStitch: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stitches: [],
|
|
||||||
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = calculateColorBlocks(pattern as PesPatternData);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]).toEqual({
|
|
||||||
colorIndex: 0,
|
|
||||||
threadHex: "#000000", // Fallback for missing thread
|
|
||||||
threadCatalogNumber: null,
|
|
||||||
threadBrand: null,
|
|
||||||
threadDescription: null,
|
|
||||||
threadChart: null,
|
|
||||||
startStitch: 0,
|
|
||||||
endStitch: 50,
|
|
||||||
stitchCount: 50,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle null thread metadata fields", () => {
|
|
||||||
const pattern: Partial<PesPatternData> = {
|
|
||||||
threads: [
|
|
||||||
{
|
|
||||||
color: 1,
|
|
||||||
hex: "#0000FF",
|
|
||||||
brand: null,
|
|
||||||
catalogNumber: null,
|
|
||||||
description: null,
|
|
||||||
chart: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
penStitches: {
|
|
||||||
colorBlocks: [
|
|
||||||
{
|
|
||||||
startStitchIndex: 0,
|
|
||||||
endStitchIndex: 30,
|
|
||||||
colorIndex: 0,
|
|
||||||
startStitch: 0,
|
|
||||||
endStitch: 30,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stitches: [],
|
|
||||||
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = calculateColorBlocks(pattern as PesPatternData);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]).toEqual({
|
|
||||||
colorIndex: 0,
|
|
||||||
threadHex: "#0000FF",
|
|
||||||
threadCatalogNumber: null,
|
|
||||||
threadBrand: null,
|
|
||||||
threadDescription: null,
|
|
||||||
threadChart: null,
|
|
||||||
startStitch: 0,
|
|
||||||
endStitch: 30,
|
|
||||||
stitchCount: 30,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findCurrentBlockIndex", () => {
|
|
||||||
const colorBlocks = [
|
|
||||||
{
|
|
||||||
colorIndex: 0,
|
|
||||||
threadHex: "#FF0000",
|
|
||||||
threadCatalogNumber: "001",
|
|
||||||
threadBrand: "Brother",
|
|
||||||
threadDescription: "Red",
|
|
||||||
threadChart: "A",
|
|
||||||
startStitch: 0,
|
|
||||||
endStitch: 100,
|
|
||||||
stitchCount: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
colorIndex: 1,
|
|
||||||
threadHex: "#00FF00",
|
|
||||||
threadCatalogNumber: "002",
|
|
||||||
threadBrand: "Brother",
|
|
||||||
threadDescription: "Green",
|
|
||||||
threadChart: "B",
|
|
||||||
startStitch: 100,
|
|
||||||
endStitch: 250,
|
|
||||||
stitchCount: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
colorIndex: 2,
|
|
||||||
threadHex: "#0000FF",
|
|
||||||
threadCatalogNumber: "003",
|
|
||||||
threadBrand: "Brother",
|
|
||||||
threadDescription: "Blue",
|
|
||||||
threadChart: "C",
|
|
||||||
startStitch: 250,
|
|
||||||
endStitch: 400,
|
|
||||||
stitchCount: 150,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("should find block containing stitch at start boundary", () => {
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, 0)).toBe(0);
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, 100)).toBe(1);
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, 250)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should find block containing stitch in middle", () => {
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, 50)).toBe(0);
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, 150)).toBe(1);
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, 300)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return -1 for stitch before first block", () => {
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, -1)).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return -1 for stitch at or after last block end", () => {
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, 400)).toBe(-1);
|
|
||||||
expect(findCurrentBlockIndex(colorBlocks, 500)).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return -1 for empty color blocks array", () => {
|
|
||||||
expect(findCurrentBlockIndex([], 50)).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should find block with single color block", () => {
|
|
||||||
const singleBlock = [colorBlocks[0]];
|
|
||||||
expect(findCurrentBlockIndex(singleBlock, 0)).toBe(0);
|
|
||||||
expect(findCurrentBlockIndex(singleBlock, 50)).toBe(0);
|
|
||||||
expect(findCurrentBlockIndex(singleBlock, 99)).toBe(0);
|
|
||||||
expect(findCurrentBlockIndex(singleBlock, 100)).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
/**
|
|
||||||
* Color Block Helpers
|
|
||||||
*
|
|
||||||
* Utility functions for calculating color block information from pattern data.
|
|
||||||
* Extracted from ProgressMonitor component for better testability and reusability.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { PesPatternData } from "../formats/import/client";
|
|
||||||
import type { ColorBlock } from "../components/ProgressMonitor/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate color blocks from decoded PEN pattern data
|
|
||||||
*
|
|
||||||
* Transforms PEN color blocks into enriched ColorBlock objects with thread metadata.
|
|
||||||
* Returns an empty array if pattern or penStitches data is unavailable.
|
|
||||||
*
|
|
||||||
* @param displayPattern - The PES pattern data containing penStitches and threads
|
|
||||||
* @returns Array of ColorBlock objects with thread information and stitch counts
|
|
||||||
*/
|
|
||||||
export function calculateColorBlocks(
|
|
||||||
displayPattern: PesPatternData | null,
|
|
||||||
): ColorBlock[] {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the index of the color block containing a specific stitch
|
|
||||||
*
|
|
||||||
* @param colorBlocks - Array of color blocks to search
|
|
||||||
* @param currentStitch - The stitch index to find
|
|
||||||
* @returns The index of the containing block, or -1 if not found
|
|
||||||
*/
|
|
||||||
export function findCurrentBlockIndex(
|
|
||||||
colorBlocks: ColorBlock[],
|
|
||||||
currentStitch: number,
|
|
||||||
): number {
|
|
||||||
return colorBlocks.findIndex(
|
|
||||||
(block) =>
|
|
||||||
currentStitch >= block.startStitch && currentStitch < block.endStitch,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 ||
|
|
||||||
""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
/**
|
|
||||||
* 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(" • ");
|
|
||||||
}
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "node",
|
||||||
include: ["src/**/*.{test,spec}.{js,ts,tsx}"],
|
include: ["src/**/*.{test,spec}.{js,ts}"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue