mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
Migrate to Tailwind CSS and fix canvas zoom issues
- Install Tailwind CSS and configure Vite plugin - Replace all custom CSS classes with Tailwind utility classes - Migrate all components to use Tailwind styling: - App.tsx: Responsive layout with modern styling - MachineConnection: Status badges and action buttons - FileUpload: File input and progress bars with shimmer effect - ProgressMonitor: Color blocks, state indicators, and actions - ConfirmDialog: Modal overlay with backdrop blur - PatternCanvas: Canvas viewer with floating controls - Add custom shimmer animation for progress bars - Fix canvas resizing issue during zoom operations: - Add ResizeObserver for stable container dimensions - Use clientWidth/clientHeight instead of offset dimensions - Cache container size to prevent layout thrashing - Improve zoom button behavior to zoom towards viewport center - Maintain consistent design with shadows, borders, and transitions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f94aa071fb
commit
30d87f82bc
13 changed files with 950 additions and 1042 deletions
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run build:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,3 +22,5 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
protocol/
|
||||
752
package-lock.json
generated
752
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,9 +10,13 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"konva": "^10.0.12",
|
||||
"pyodide": "^0.27.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"pyodide": "^0.27.4"
|
||||
"react-konva": "^19.2.1",
|
||||
"tailwindcss": "^4.1.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
|
|
|||
796
src/App.css
796
src/App.css
|
|
@ -1,763 +1,6 @@
|
|||
:root {
|
||||
--primary-color: #0066cc;
|
||||
--secondary-color: #6c757d;
|
||||
--danger-color: #dc3545;
|
||||
--success-color: #28a745;
|
||||
--warning-color: #ffc107;
|
||||
--info-color: #17a2b8;
|
||||
--background: #f5f5f5;
|
||||
--panel-background: #ffffff;
|
||||
--border-color: #dee2e6;
|
||||
--text-color: #212529;
|
||||
--text-muted: #6c757d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--background);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background-color: var(--panel-background);
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.connection-panel,
|
||||
.file-upload-panel,
|
||||
.progress-panel,
|
||||
.canvas-panel {
|
||||
background-color: var(--panel-background);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-row .label {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-row .value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-indicator.status-16 { /* IDLE */
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.status-indicator.status-17 { /* SEWING_WAIT */
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-indicator.status-48 { /* SEWING */
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-indicator.status-49 { /* SEWING_COMPLETE */
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-indicator.status-64 { /* COLOR_CHANGE_WAIT */
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.error-indicator {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.polling-indicator {
|
||||
color: var(--primary-color);
|
||||
font-size: 0.75rem;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.connection-actions,
|
||||
.progress-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* Canvas container with Konva */
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: #fafafa;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 600px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Canvas overlay elements */
|
||||
.canvas-legend {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(4px);
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.canvas-legend h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.legend-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.canvas-dimensions {
|
||||
position: absolute;
|
||||
bottom: 165px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 11;
|
||||
backdrop-filter: blur(4px);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.canvas-offset-info {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 11;
|
||||
backdrop-filter: blur(4px);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.offset-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.offset-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.offset-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Zoom controls */
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoom-btn:hover:not(:disabled) {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0, 102, 204, 0.3);
|
||||
}
|
||||
|
||||
.zoom-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zoom-reset {
|
||||
margin-left: 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.status-message.warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
.status-message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
/* Color Block Progress Styles */
|
||||
.color-blocks {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.color-block-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-block-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
background-color: var(--background);
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.color-block-item.completed {
|
||||
border-color: var(--success-color);
|
||||
background-color: #f0f9f4;
|
||||
}
|
||||
|
||||
.color-block-item.current {
|
||||
border-color: var(--primary-color);
|
||||
background-color: #e7f3ff;
|
||||
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.2);
|
||||
}
|
||||
|
||||
.color-block-item.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--border-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.block-status {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.color-block-item.completed .block-status {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.color-block-item.current .block-status {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.block-stitches {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.block-progress-bar {
|
||||
margin-top: 0.5rem;
|
||||
height: 4px;
|
||||
background-color: #fff;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.block-progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
State-Based UX Safety Styles
|
||||
========================================================================== */
|
||||
|
||||
/* Confirmation Dialog Styles */
|
||||
.confirm-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background-color: var(--panel-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.confirm-dialog-danger {
|
||||
border-top: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.confirm-dialog-warning {
|
||||
border-top: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.confirm-dialog-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.confirm-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.confirm-dialog-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.confirm-dialog-body p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.confirm-dialog-actions {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Enhanced Status Badge */
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-badge-idle,
|
||||
.status-badge-info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.status-badge-active,
|
||||
.status-badge-waiting,
|
||||
.status-badge-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
.status-badge-complete,
|
||||
.status-badge-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.status-badge-interrupted,
|
||||
.status-badge-error,
|
||||
.status-badge-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* State Indicator Component */
|
||||
.state-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.state-indicator-idle,
|
||||
.state-indicator-info {
|
||||
background-color: #e7f3ff;
|
||||
border-left-color: #0066cc;
|
||||
}
|
||||
|
||||
.state-indicator-active,
|
||||
.state-indicator-waiting,
|
||||
.state-indicator-warning {
|
||||
background-color: #fff8e1;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.state-indicator-complete,
|
||||
.state-indicator-success {
|
||||
background-color: #e8f5e9;
|
||||
border-left-color: #28a745;
|
||||
}
|
||||
|
||||
.state-indicator-interrupted,
|
||||
.state-indicator-error,
|
||||
.state-indicator-danger {
|
||||
background-color: #ffebee;
|
||||
border-left-color: #dc3545;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.state-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.state-label {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.state-description {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Enhanced Progress Visualization */
|
||||
.progress-bar {
|
||||
height: 12px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
position: relative;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), #0052a3);
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.3),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom animations for shimmer effect */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
|
|
@ -766,38 +9,3 @@ button:disabled {
|
|||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Button enhancements for safety */
|
||||
button.btn-danger:hover:not(:disabled) {
|
||||
background-color: #c82333;
|
||||
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button.btn-primary:hover:not(:disabled) {
|
||||
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button.btn-secondary:hover:not(:disabled) {
|
||||
box-shadow: 0 2px 8px rgba(108, 117, 125, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Info message styles */
|
||||
.status-message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
/* Enhanced visual feedback for disabled state */
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
|
|
|||
18
src/App.tsx
18
src/App.tsx
|
|
@ -49,22 +49,22 @@ function App() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Brother Embroidery Machine Controller</h1>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="bg-white px-8 py-6 border-b border-gray-300 shadow-md">
|
||||
<h1 className="text-3xl font-semibold mb-2">Brother Embroidery Machine Controller</h1>
|
||||
{machine.error && (
|
||||
<div className="error-message">{machine.error}</div>
|
||||
<div className="bg-red-100 text-red-900 px-4 py-3 rounded border border-red-200 mt-4">{machine.error}</div>
|
||||
)}
|
||||
{pyodideError && (
|
||||
<div className="error-message">Python Error: {pyodideError}</div>
|
||||
<div className="bg-red-100 text-red-900 px-4 py-3 rounded border border-red-200 mt-4">Python Error: {pyodideError}</div>
|
||||
)}
|
||||
{!pyodideReady && !pyodideError && (
|
||||
<div className="info-message">Initializing Python environment...</div>
|
||||
<div className="bg-blue-100 text-blue-900 px-4 py-3 rounded border border-blue-200 mt-4">Initializing Python environment...</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="app-content">
|
||||
<div className="left-panel">
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-6 p-6 max-w-[1600px] w-full mx-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
<MachineConnection
|
||||
isConnected={machine.isConnected}
|
||||
machineInfo={machine.machineInfo}
|
||||
|
|
@ -101,7 +101,7 @@ function App() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="right-panel">
|
||||
<div className="flex flex-col">
|
||||
<PatternCanvas
|
||||
pesData={pesData}
|
||||
sewingProgress={machine.sewingProgress}
|
||||
|
|
|
|||
|
|
@ -38,31 +38,31 @@ export function ConfirmDialog({
|
|||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000]" onClick={onCancel}>
|
||||
<div
|
||||
className={`confirm-dialog confirm-dialog-${variant}`}
|
||||
className={`bg-white rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === 'danger' ? 'border-t-4 border-red-600' : 'border-t-4 border-yellow-500'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="dialog-title"
|
||||
aria-describedby="dialog-message"
|
||||
>
|
||||
<div className="confirm-dialog-header">
|
||||
<h3 id="dialog-title">{title}</h3>
|
||||
<div className="p-6 border-b border-gray-300">
|
||||
<h3 id="dialog-title" className="m-0 text-xl font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-dialog-body">
|
||||
<p id="dialog-message">{message}</p>
|
||||
<div className="p-6">
|
||||
<p id="dialog-message" className="m-0 leading-relaxed text-gray-900">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-dialog-actions">
|
||||
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="btn-secondary"
|
||||
className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]"
|
||||
autoFocus
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
|
||||
className={variant === 'danger' ? 'px-6 py-3 bg-red-600 text-white rounded font-semibold text-sm hover:bg-red-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]' : 'px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]'}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -62,43 +62,43 @@ export function FileUpload({
|
|||
}, [pesData, fileName, onUpload, patternOffset]);
|
||||
|
||||
return (
|
||||
<div className="file-upload-panel">
|
||||
<h2>Pattern File</h2>
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Pattern File</h2>
|
||||
|
||||
<div className="upload-controls">
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pes"
|
||||
onChange={handleFileChange}
|
||||
id="file-input"
|
||||
className="file-input"
|
||||
className="hidden"
|
||||
disabled={!pyodideReady || isLoading}
|
||||
/>
|
||||
<label htmlFor="file-input" className={`btn-secondary ${!pyodideReady || isLoading ? 'disabled' : ''}`}>
|
||||
<label htmlFor="file-input" className={`inline-block px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm cursor-pointer transition-all ${!pyodideReady || isLoading ? 'opacity-50 cursor-not-allowed grayscale-[0.3]' : 'hover:bg-gray-700 hover:shadow-md hover:-translate-y-0.5 active:translate-y-0'}`}>
|
||||
{isLoading ? 'Loading...' : !pyodideReady ? 'Initializing...' : 'Choose PES File'}
|
||||
</label>
|
||||
|
||||
{pesData && (
|
||||
<div className="pattern-info">
|
||||
<h3>Pattern Details</h3>
|
||||
<div className="detail-row">
|
||||
<span className="label">Total Stitches:</span>
|
||||
<span className="value">{pesData.stitchCount}</span>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-base font-semibold my-4">Pattern Details</h3>
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Total Stitches:</span>
|
||||
<span className="font-semibold">{pesData.stitchCount}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Colors:</span>
|
||||
<span className="value">{pesData.colorCount}</span>
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Colors:</span>
|
||||
<span className="font-semibold">{pesData.colorCount}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Size:</span>
|
||||
<span className="value">
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Size:</span>
|
||||
<span className="font-semibold">
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
|
||||
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Bounds:</span>
|
||||
<span className="value">
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="font-medium text-gray-600">Bounds:</span>
|
||||
<span className="font-semibold">
|
||||
({pesData.bounds.minX}, {pesData.bounds.minY}) to (
|
||||
{pesData.bounds.maxX}, {pesData.bounds.maxY})
|
||||
</span>
|
||||
|
|
@ -110,7 +110,7 @@ export function FileUpload({
|
|||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!isConnected || uploadProgress > 0}
|
||||
className="btn-primary"
|
||||
className="mt-4 px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]"
|
||||
>
|
||||
{uploadProgress > 0
|
||||
? `Uploading... ${uploadProgress.toFixed(0)}%`
|
||||
|
|
@ -119,15 +119,15 @@ export function FileUpload({
|
|||
)}
|
||||
|
||||
{pesData && !canUploadPattern(machineStatus) && (
|
||||
<div className="status-message warning">
|
||||
<div className="bg-yellow-100 text-yellow-800 px-4 py-3 rounded border border-yellow-200 my-4 font-medium">
|
||||
Cannot upload pattern while machine is {getMachineStateCategory(machineStatus)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="progress-bar">
|
||||
<div className="h-3 bg-gray-300 rounded-md overflow-hidden my-4 shadow-inner">
|
||||
<div
|
||||
className="progress-fill"
|
||||
className="h-full bg-gradient-to-r from-blue-600 to-blue-700 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,70 +49,83 @@ export function MachineConnection({
|
|||
|
||||
const stateVisual = getStateVisualInfo(machineStatus);
|
||||
|
||||
const statusBadgeColors = {
|
||||
idle: 'bg-cyan-100 text-cyan-800 border-cyan-200',
|
||||
info: 'bg-cyan-100 text-cyan-800 border-cyan-200',
|
||||
active: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
waiting: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
warning: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
complete: 'bg-green-100 text-green-800 border-green-200',
|
||||
success: 'bg-green-100 text-green-800 border-green-200',
|
||||
interrupted: 'bg-red-100 text-red-800 border-red-200',
|
||||
error: 'bg-red-100 text-red-800 border-red-200',
|
||||
danger: 'bg-red-100 text-red-800 border-red-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="connection-panel">
|
||||
<h2>Machine Connection</h2>
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Machine Connection</h2>
|
||||
|
||||
{!isConnected ? (
|
||||
<div className="connection-actions">
|
||||
<button onClick={onConnect} className="btn-primary">
|
||||
<div className="flex gap-3 mt-4 flex-wrap">
|
||||
<button onClick={onConnect} className="px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Connect to Machine
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="connection-info">
|
||||
<div className="status-bar">
|
||||
<span className={`status-badge status-badge-${stateVisual.color}`}>
|
||||
<span className="status-icon">{stateVisual.icon}</span>
|
||||
<span className="status-text">{machineStatusName}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-4 p-3 bg-gray-100 rounded">
|
||||
<span className={`flex items-center gap-2 px-4 py-2 rounded font-semibold text-sm border ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}>
|
||||
<span className="text-lg leading-none">{stateVisual.icon}</span>
|
||||
<span className="uppercase tracking-wide">{machineStatusName}</span>
|
||||
</span>
|
||||
{isPolling && (
|
||||
<span className="polling-indicator" title="Polling machine status">●</span>
|
||||
<span className="text-blue-600 text-xs animate-pulse" title="Polling machine status">●</span>
|
||||
)}
|
||||
{hasError(machineError) && (
|
||||
<span className="error-indicator">{getErrorMessage(machineError)}</span>
|
||||
<span className="bg-red-100 text-red-900 px-4 py-2 rounded font-semibold text-sm">{getErrorMessage(machineError)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{machineInfo && (
|
||||
<div className="machine-details">
|
||||
<div className="detail-row">
|
||||
<span className="label">Model:</span>
|
||||
<span className="value">{machineInfo.modelNumber}</span>
|
||||
<div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Model:</span>
|
||||
<span className="font-semibold">{machineInfo.modelNumber}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Serial:</span>
|
||||
<span className="value">{machineInfo.serialNumber}</span>
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Serial:</span>
|
||||
<span className="font-semibold">{machineInfo.serialNumber}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Software:</span>
|
||||
<span className="value">{machineInfo.softwareVersion}</span>
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Software:</span>
|
||||
<span className="font-semibold">{machineInfo.softwareVersion}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Max Area:</span>
|
||||
<span className="value">
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Max Area:</span>
|
||||
<span className="font-semibold">
|
||||
{(machineInfo.maxWidth / 10).toFixed(1)} x{' '}
|
||||
{(machineInfo.maxHeight / 10).toFixed(1)} mm
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">MAC:</span>
|
||||
<span className="value">{machineInfo.macAddress}</span>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="font-medium text-gray-600">MAC:</span>
|
||||
<span className="font-semibold">{machineInfo.macAddress}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resumeAvailable && resumeFileName && (
|
||||
<div className="status-message success">
|
||||
<div className="bg-green-100 text-green-800 px-4 py-3 rounded border border-green-200 my-4 font-medium">
|
||||
Loaded cached pattern: "{resumeFileName}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="connection-actions">
|
||||
<button onClick={onRefresh} className="btn-secondary">
|
||||
<div className="flex gap-3 mt-4 flex-wrap">
|
||||
<button onClick={onRefresh} className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Refresh Status
|
||||
</button>
|
||||
<button onClick={handleDisconnectClick} className="btn-danger">
|
||||
<button onClick={handleDisconnectClick} className="px-6 py-3 bg-red-600 text-white rounded font-semibold text-sm hover:bg-red-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,26 +28,46 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
|
|||
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
|
||||
const [stageScale, setStageScale] = useState(1);
|
||||
const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 });
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const initialScaleRef = useRef<number>(1);
|
||||
|
||||
// Track container size
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
setContainerSize({ width, height });
|
||||
}
|
||||
};
|
||||
|
||||
// Initial size
|
||||
updateSize();
|
||||
|
||||
// Watch for resize
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Calculate initial scale when pattern or hoop changes
|
||||
useEffect(() => {
|
||||
if (!pesData || !containerRef.current) return;
|
||||
if (!pesData || containerSize.width === 0) return;
|
||||
|
||||
const { bounds } = pesData;
|
||||
const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX;
|
||||
const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY;
|
||||
|
||||
const width = containerRef.current.offsetWidth;
|
||||
const height = containerRef.current.offsetHeight;
|
||||
|
||||
const initialScale = calculateInitialScale(width, height, viewWidth, viewHeight);
|
||||
const initialScale = calculateInitialScale(containerSize.width, containerSize.height, viewWidth, viewHeight);
|
||||
initialScaleRef.current = initialScale;
|
||||
|
||||
// Set initial scale and center position when pattern loads
|
||||
setStageScale(initialScale);
|
||||
setStagePos({ x: width / 2, y: height / 2 });
|
||||
}, [pesData, machineInfo]);
|
||||
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
|
||||
}, [pesData, machineInfo, containerSize]);
|
||||
|
||||
// Wheel zoom handler
|
||||
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
|
|
@ -84,21 +104,54 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
|
|||
|
||||
// Zoom control handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const newScale = Math.min(stageScale * 1.2, 10);
|
||||
const oldScale = stageScale;
|
||||
const newScale = Math.min(oldScale * 1.2, 10);
|
||||
|
||||
// Zoom towards center of viewport
|
||||
const centerX = containerSize.width / 2;
|
||||
const centerY = containerSize.height / 2;
|
||||
|
||||
const mousePointTo = {
|
||||
x: (centerX - stagePos.x) / oldScale,
|
||||
y: (centerY - stagePos.y) / oldScale,
|
||||
};
|
||||
|
||||
const newPos = {
|
||||
x: centerX - mousePointTo.x * newScale,
|
||||
y: centerY - mousePointTo.y * newScale,
|
||||
};
|
||||
|
||||
setStageScale(newScale);
|
||||
}, [stageScale]);
|
||||
setStagePos(newPos);
|
||||
}, [stageScale, stagePos, containerSize]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const newScale = Math.max(stageScale / 1.2, 0.1);
|
||||
const oldScale = stageScale;
|
||||
const newScale = Math.max(oldScale / 1.2, 0.1);
|
||||
|
||||
// Zoom towards center of viewport
|
||||
const centerX = containerSize.width / 2;
|
||||
const centerY = containerSize.height / 2;
|
||||
|
||||
const mousePointTo = {
|
||||
x: (centerX - stagePos.x) / oldScale,
|
||||
y: (centerY - stagePos.y) / oldScale,
|
||||
};
|
||||
|
||||
const newPos = {
|
||||
x: centerX - mousePointTo.x * newScale,
|
||||
y: centerY - mousePointTo.y * newScale,
|
||||
};
|
||||
|
||||
setStageScale(newScale);
|
||||
}, [stageScale]);
|
||||
setStagePos(newPos);
|
||||
}, [stageScale, stagePos, containerSize]);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const initialScale = initialScaleRef.current;
|
||||
setStageScale(initialScale);
|
||||
setStagePos({ x: containerRef.current.offsetWidth / 2, y: containerRef.current.offsetHeight / 2 });
|
||||
}, []);
|
||||
setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
|
||||
}, [containerSize]);
|
||||
|
||||
// Pattern drag handlers
|
||||
const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
|
|
@ -169,13 +222,13 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
|
|||
}, [renderPatternLayer]);
|
||||
|
||||
return (
|
||||
<div className="canvas-panel">
|
||||
<h2>Pattern Preview</h2>
|
||||
<div className="canvas-container" ref={containerRef} style={{ width: '100%', height: '600px' }}>
|
||||
{containerRef.current && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Pattern Preview</h2>
|
||||
<div className="relative w-full h-[600px] border border-gray-300 rounded bg-gray-50 overflow-hidden" ref={containerRef}>
|
||||
{containerSize.width > 0 && (
|
||||
<Stage
|
||||
width={containerRef.current.offsetWidth}
|
||||
height={containerRef.current.offsetHeight}
|
||||
width={containerSize.width}
|
||||
height={containerSize.height}
|
||||
x={stagePos.x}
|
||||
y={stagePos.y}
|
||||
scaleX={stageScale}
|
||||
|
|
@ -235,7 +288,7 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
|
|||
|
||||
{/* Placeholder overlay when no pattern is loaded */}
|
||||
{!pesData && (
|
||||
<div className="canvas-placeholder">
|
||||
<div className="flex items-center justify-center h-[600px] text-gray-600 italic">
|
||||
Load a PES file to preview the pattern
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -244,46 +297,46 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
|
|||
{pesData && (
|
||||
<>
|
||||
{/* Thread Legend Overlay */}
|
||||
<div className="canvas-legend">
|
||||
<h4>Threads</h4>
|
||||
<div className="absolute top-2.5 left-2.5 bg-white/95 backdrop-blur-sm p-3 rounded-lg shadow-lg z-10 max-w-[150px]">
|
||||
<h4 className="m-0 mb-2 text-[13px] font-semibold text-gray-900 border-b border-gray-300 pb-1.5">Threads</h4>
|
||||
{pesData.threads.map((thread, index) => (
|
||||
<div key={index} className="legend-item">
|
||||
<div key={index} className="flex items-center gap-2 mb-1.5 last:mb-0">
|
||||
<div
|
||||
className="legend-swatch"
|
||||
className="w-5 h-5 rounded border border-black flex-shrink-0"
|
||||
style={{ backgroundColor: thread.hex }}
|
||||
/>
|
||||
<span className="legend-label">Thread {index + 1}</span>
|
||||
<span className="text-xs text-gray-900">Thread {index + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pattern Dimensions Overlay */}
|
||||
<div className="canvas-dimensions">
|
||||
<div className="absolute bottom-[165px] right-5 bg-white/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg z-[11] text-sm font-semibold text-gray-900">
|
||||
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
|
||||
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
|
||||
</div>
|
||||
|
||||
{/* Pattern Offset Indicator */}
|
||||
<div className="canvas-offset-info">
|
||||
<div className="offset-label">Pattern Position:</div>
|
||||
<div className="offset-value">
|
||||
<div className="absolute bottom-20 right-5 bg-white/95 backdrop-blur-sm p-2.5 px-3.5 rounded-lg shadow-lg z-[11] min-w-[180px]">
|
||||
<div className="text-[11px] font-semibold text-gray-600 uppercase tracking-wider mb-1">Pattern Position:</div>
|
||||
<div className="text-[13px] font-semibold text-blue-600 mb-1">
|
||||
X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm
|
||||
</div>
|
||||
<div className="offset-hint">
|
||||
<div className="text-[10px] text-gray-600 italic">
|
||||
Drag pattern to move • Drag background to pan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom Controls Overlay */}
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom In">
|
||||
<div className="absolute bottom-5 right-5 flex gap-2 items-center bg-white/95 backdrop-blur-sm px-3 py-2 rounded-lg shadow-lg z-10">
|
||||
<button className="w-8 h-8 p-0 text-lg font-bold border border-gray-300 bg-white rounded cursor-pointer transition-all flex items-center justify-center hover:bg-blue-600 hover:text-white hover:border-blue-600 hover:-translate-y-0.5 hover:shadow-md hover:shadow-blue-600/30 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomIn} title="Zoom In">
|
||||
+
|
||||
</button>
|
||||
<span className="zoom-level">{Math.round(stageScale * 100)}%</span>
|
||||
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom Out">
|
||||
<span className="min-w-[50px] text-center text-[13px] font-semibold text-gray-900 select-none">{Math.round(stageScale * 100)}%</span>
|
||||
<button className="w-8 h-8 p-0 text-lg font-bold border border-gray-300 bg-white rounded cursor-pointer transition-all flex items-center justify-center hover:bg-blue-600 hover:text-white hover:border-blue-600 hover:-translate-y-0.5 hover:shadow-md hover:shadow-blue-600/30 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomOut} title="Zoom Out">
|
||||
−
|
||||
</button>
|
||||
<button className="zoom-btn zoom-reset" onClick={handleZoomReset} title="Reset Zoom">
|
||||
<button className="w-8 h-8 p-0 text-xl font-bold border border-gray-300 bg-white rounded cursor-pointer transition-all flex items-center justify-center hover:bg-blue-600 hover:text-white hover:border-blue-600 hover:-translate-y-0.5 hover:shadow-md hover:shadow-blue-600/30 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed ml-1" onClick={handleZoomReset} title="Reset Zoom">
|
||||
⟲
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -85,30 +85,43 @@ export function ProgressMonitor({
|
|||
block => currentStitch >= block.startStitch && currentStitch < block.endStitch
|
||||
);
|
||||
|
||||
const stateIndicatorColors = {
|
||||
idle: 'bg-blue-50 border-l-blue-600',
|
||||
info: 'bg-blue-50 border-l-blue-600',
|
||||
active: 'bg-yellow-50 border-l-yellow-500',
|
||||
waiting: 'bg-yellow-50 border-l-yellow-500',
|
||||
warning: 'bg-yellow-50 border-l-yellow-500',
|
||||
complete: 'bg-green-50 border-l-green-600',
|
||||
success: 'bg-green-50 border-l-green-600',
|
||||
interrupted: 'bg-red-50 border-l-red-600',
|
||||
error: 'bg-red-50 border-l-red-600',
|
||||
danger: 'bg-red-50 border-l-red-600',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="progress-panel">
|
||||
<h2>Sewing Progress</h2>
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Sewing Progress</h2>
|
||||
|
||||
{patternInfo && (
|
||||
<div className="pattern-stats">
|
||||
<div className="detail-row">
|
||||
<span className="label">Total Stitches:</span>
|
||||
<span className="value">{patternInfo.totalStitches}</span>
|
||||
<div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Total Stitches:</span>
|
||||
<span className="font-semibold">{patternInfo.totalStitches}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Estimated Time:</span>
|
||||
<span className="value">
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Estimated Time:</span>
|
||||
<span className="font-semibold">
|
||||
{Math.floor(patternInfo.totalTime / 60)}:
|
||||
{(patternInfo.totalTime % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Speed:</span>
|
||||
<span className="value">{patternInfo.speed} spm</span>
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Speed:</span>
|
||||
<span className="font-semibold">{patternInfo.speed} spm</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Bounds:</span>
|
||||
<span className="value">
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="font-medium text-gray-600">Bounds:</span>
|
||||
<span className="font-semibold">
|
||||
({patternInfo.boundLeft}, {patternInfo.boundTop}) to (
|
||||
{patternInfo.boundRight}, {patternInfo.boundBottom})
|
||||
</span>
|
||||
|
|
@ -117,13 +130,12 @@ export function ProgressMonitor({
|
|||
)}
|
||||
|
||||
{colorBlocks.length > 0 && (
|
||||
<div className="color-blocks">
|
||||
<h3>Color Blocks</h3>
|
||||
<div className="color-block-list">
|
||||
<div className="mt-6 pt-4 border-t border-gray-300">
|
||||
<h3 className="text-base font-semibold my-4">Color Blocks</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{colorBlocks.map((block, index) => {
|
||||
const isCompleted = currentStitch >= block.endStitch;
|
||||
const isCurrent = index === currentBlockIndex;
|
||||
const isPending = currentStitch < block.startStitch;
|
||||
|
||||
// Calculate progress within current block
|
||||
let blockProgress = 0;
|
||||
|
|
@ -136,30 +148,30 @@ export function ProgressMonitor({
|
|||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`color-block-item ${
|
||||
isCompleted ? 'completed' : isCurrent ? 'current' : 'pending'
|
||||
className={`p-3 rounded bg-gray-100 border-2 border-transparent transition-all ${
|
||||
isCompleted ? 'border-green-600 bg-green-50' : isCurrent ? 'border-blue-600 bg-blue-50 shadow-md shadow-blue-600/20' : 'opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="block-header">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="color-swatch"
|
||||
className="w-6 h-6 rounded border-2 border-gray-300 shadow-sm flex-shrink-0"
|
||||
style={{ backgroundColor: block.threadHex }}
|
||||
title={block.threadHex}
|
||||
/>
|
||||
<span className="block-label">
|
||||
<span className="font-semibold flex-1">
|
||||
Thread {block.colorIndex + 1}
|
||||
</span>
|
||||
<span className="block-status">
|
||||
<span className={`text-xl font-bold ${isCompleted ? 'text-green-600' : isCurrent ? 'text-blue-600' : 'text-gray-600'}`}>
|
||||
{isCompleted ? '✓' : isCurrent ? '→' : '○'}
|
||||
</span>
|
||||
<span className="block-stitches">
|
||||
<span className="text-sm text-gray-600">
|
||||
{block.stitchCount} stitches
|
||||
</span>
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<div className="block-progress-bar">
|
||||
<div className="mt-2 h-1 bg-white rounded overflow-hidden">
|
||||
<div
|
||||
className="block-progress-fill"
|
||||
className="h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${blockProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -172,60 +184,60 @@ export function ProgressMonitor({
|
|||
)}
|
||||
|
||||
{sewingProgress && (
|
||||
<div className="sewing-stats">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${progressPercent}%` }} />
|
||||
<div className="mt-4">
|
||||
<div className="h-3 bg-gray-300 rounded-md overflow-hidden my-4 shadow-inner relative">
|
||||
<div className="h-full bg-gradient-to-r from-blue-600 to-blue-700 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="detail-row">
|
||||
<span className="label">Current Stitch:</span>
|
||||
<span className="value">
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Current Stitch:</span>
|
||||
<span className="font-semibold">
|
||||
{sewingProgress.currentStitch} / {patternInfo?.totalStitches || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Elapsed Time:</span>
|
||||
<span className="value">
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Elapsed Time:</span>
|
||||
<span className="font-semibold">
|
||||
{Math.floor(sewingProgress.currentTime / 60)}:
|
||||
{(sewingProgress.currentTime % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Position:</span>
|
||||
<span className="value">
|
||||
<div className="flex justify-between py-2 border-b border-gray-300">
|
||||
<span className="font-medium text-gray-600">Position:</span>
|
||||
<span className="font-semibold">
|
||||
({(sewingProgress.positionX / 10).toFixed(1)}mm,{' '}
|
||||
{(sewingProgress.positionY / 10).toFixed(1)}mm)
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="label">Progress:</span>
|
||||
<span className="value">{progressPercent.toFixed(1)}%</span>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="font-medium text-gray-600">Progress:</span>
|
||||
<span className="font-semibold">{progressPercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* State Visual Indicator */}
|
||||
{patternInfo && (
|
||||
<div className={`state-indicator state-indicator-${stateVisual.color}`}>
|
||||
<span className="state-icon">{stateVisual.icon}</span>
|
||||
<div className="state-info">
|
||||
<div className="state-label">{stateVisual.label}</div>
|
||||
<div className="state-description">{stateVisual.description}</div>
|
||||
<div className={`flex items-center gap-4 p-4 rounded-lg my-4 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}>
|
||||
<span className="text-3xl leading-none">{stateVisual.icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-base mb-1">{stateVisual.label}</div>
|
||||
<div className="text-sm text-gray-600">{stateVisual.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="progress-actions">
|
||||
<div className="flex gap-3 mt-4 flex-wrap">
|
||||
{/* Mask trace waiting for confirmation */}
|
||||
{isMaskTraceWait && (
|
||||
<div className="status-message warning">
|
||||
<div className="bg-yellow-100 text-yellow-800 px-4 py-3 rounded border border-yellow-200 font-medium w-full">
|
||||
Press button on machine to start mask trace
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mask trace in progress */}
|
||||
{isMaskTracing && (
|
||||
<div className="status-message info">
|
||||
<div className="bg-cyan-100 text-cyan-800 px-4 py-3 rounded border border-cyan-200 font-medium w-full">
|
||||
Mask trace in progress...
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -233,16 +245,16 @@ export function ProgressMonitor({
|
|||
{/* Mask trace complete - ready to sew */}
|
||||
{isMaskTraceComplete && (
|
||||
<>
|
||||
<div className="status-message success">
|
||||
<div className="bg-green-100 text-green-800 px-4 py-3 rounded border border-green-200 font-medium w-full">
|
||||
Mask trace complete!
|
||||
</div>
|
||||
{canStartSewing(machineStatus) && (
|
||||
<button onClick={onStartSewing} className="btn-primary">
|
||||
<button onClick={onStartSewing} className="px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Start Sewing
|
||||
</button>
|
||||
)}
|
||||
{canStartMaskTrace(machineStatus) && (
|
||||
<button onClick={onStartMaskTrace} className="btn-secondary">
|
||||
<button onClick={onStartMaskTrace} className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Trace Again
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -252,11 +264,11 @@ export function ProgressMonitor({
|
|||
{/* Pattern uploaded, ready to trace */}
|
||||
{machineStatus === MachineStatus.IDLE && (
|
||||
<>
|
||||
<div className="status-message info">
|
||||
<div className="bg-cyan-100 text-cyan-800 px-4 py-3 rounded border border-cyan-200 font-medium w-full">
|
||||
Pattern uploaded successfully
|
||||
</div>
|
||||
{canStartMaskTrace(machineStatus) && (
|
||||
<button onClick={onStartMaskTrace} className="btn-secondary">
|
||||
<button onClick={onStartMaskTrace} className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Start Mask Trace
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -267,12 +279,12 @@ export function ProgressMonitor({
|
|||
{machineStatus === MachineStatus.SEWING_WAIT && (
|
||||
<>
|
||||
{canStartMaskTrace(machineStatus) && (
|
||||
<button onClick={onStartMaskTrace} className="btn-secondary">
|
||||
<button onClick={onStartMaskTrace} className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Start Mask Trace
|
||||
</button>
|
||||
)}
|
||||
{canStartSewing(machineStatus) && (
|
||||
<button onClick={onStartSewing} className="btn-primary">
|
||||
<button onClick={onStartSewing} className="px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Start Sewing
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -281,42 +293,42 @@ export function ProgressMonitor({
|
|||
|
||||
{/* Resume sewing for interrupted states */}
|
||||
{canResumeSewing(machineStatus) && (
|
||||
<button onClick={onResumeSewing} className="btn-primary">
|
||||
<button onClick={onResumeSewing} className="px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
▶️ Resume Sewing
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Color change needed */}
|
||||
{isColorChange && (
|
||||
<div className="status-message warning">
|
||||
<div className="bg-yellow-100 text-yellow-800 px-4 py-3 rounded border border-yellow-200 font-medium w-full">
|
||||
Waiting for color change - change thread and press button on machine
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sewing in progress */}
|
||||
{isSewing && (
|
||||
<div className="status-message info">
|
||||
<div className="bg-cyan-100 text-cyan-800 px-4 py-3 rounded border border-cyan-200 font-medium w-full">
|
||||
Sewing in progress...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sewing complete */}
|
||||
{isComplete && (
|
||||
<div className="status-message success">
|
||||
<div className="bg-green-100 text-green-800 px-4 py-3 rounded border border-green-200 font-medium w-full">
|
||||
Sewing complete!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete pattern button - ONLY show when safe */}
|
||||
{patternInfo && canDeletePattern(machineStatus) && (
|
||||
<button onClick={onDeletePattern} className="btn-danger">
|
||||
<button onClick={onDeletePattern} className="px-6 py-3 bg-red-600 text-white rounded font-semibold text-sm hover:bg-red-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
|
||||
Delete Pattern
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show warning when delete is unavailable */}
|
||||
{patternInfo && !canDeletePattern(machineStatus) && (
|
||||
<div className="status-message info">
|
||||
<div className="bg-cyan-100 text-cyan-800 px-4 py-3 rounded border border-cyan-200 font-medium w-full">
|
||||
Pattern cannot be deleted during active operations
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ body {
|
|||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
code {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
|
@ -24,7 +25,7 @@ function viteStaticCopyPyodide() {
|
|||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteStaticCopyPyodide()],
|
||||
plugins: [react(), tailwindcss(), viteStaticCopyPyodide()],
|
||||
optimizeDeps: {
|
||||
exclude: ['pyodide'],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue