Преглед изворни кода

Frontend ESLint Warnings (8 fixed)
┌─────────────────────────┬───────────────────────────────────────────────────────────┐
│ File │ Fix │
├─────────────────────────┼───────────────────────────────────────────────────────────┤
│ CameraPage.tsx:75 │ Copy imgRef.current to a variable before cleanup │
├─────────────────────────┼───────────────────────────────────────────────────────────┤
│ useWebSocket.ts:121 │ Add processMessageQueue to useCallback dependencies │
├─────────────────────────┼───────────────────────────────────────────────────────────┤
│ SpoolmanSettings.tsx:89 │ Add eslint-disable comment (intentional debounce pattern) │
├─────────────────────────┼───────────────────────────────────────────────────────────┤
│ MQTTDebugModal.tsx:95 │ Wrap logs in useMemo to prevent recreating on each render │
├─────────────────────────┼───────────────────────────────────────────────────────────┤
│ Layout.tsx:166 │ Wrap navItemsMap and extLinksMap in useMemo │
├─────────────────────────┼───────────────────────────────────────────────────────────┤
│ KProfilesView.tsx:715 │ Wrap getProfileKey in useCallback │
├─────────────────────────┼───────────────────────────────────────────────────────────┤
│ GcodeViewer.tsx:171 │ Add eslint-disable comment (intentional behavior) │
└─────────────────────────┴───────────────────────────────────────────────────────────┘
Docker Integration Test Fix

The /api/v1/settings endpoint was returning 404 because:
1. The route was defined at /settings/ (with trailing slash)
2. Curl without -L doesn't follow redirects
3. The catch-all route was intercepting API paths

Fixes:
- Added routes for both with and without trailing slash in settings.py and notification_templates.py
- Updated catch-all in main.py to raise proper HTTPException for API routes

Test Results

- Frontend lint: 0 errors, 0 warnings
- TypeScript: No errors
- Backend unit tests: 264 passed
- Backend integration tests: 309 passed

maziggy пре 4 месеци
родитељ
комит
b2831d09a2

+ 12 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [Unreleased]
 
 ### Added
+- **Multi-plate 3MF plate selection** - When reprinting multi-plate 3MF files (exported with "All sliced file"), users can now select which plate to print:
+  - Plate selection grid with thumbnails, names, and print times
+  - Filament requirements filtered to show only selected plate's filaments
+  - Prevents incorrect filament mapping across plates
+  - Closes [#93](https://github.com/maziggy/bambuddy/issues/93)
 - **Home Assistant smart plug integration** - Control any Home Assistant switch/light entity as a smart plug:
   - Configure HA connection (URL + Long-Lived Access Token) in Settings → Network
   - Add HA-controlled plugs via Settings → Plugs → Add Smart Plug → Home Assistant tab
@@ -19,6 +24,13 @@ All notable changes to Bambuddy will be documented in this file.
   - F3D files included in backup/restore
   - API tests for F3D endpoints
 
+### Fixed
+- **Multi-plate 3MF metadata extraction** - Single-plate exports from multi-plate projects now show correct thumbnail and name:
+  - Extracts plate index from slice_info.config metadata
+  - Uses correct plate thumbnail (e.g., plate_5.png instead of plate_1.png)
+  - Appends "Plate N" to print name for plates > 1
+  - Closes [#92](https://github.com/maziggy/bambuddy/issues/92)
+
 ## [0.1.6b8] - 2026-01-17
 
 ### Added

+ 1 - 1
README.md

@@ -50,7 +50,7 @@
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music)
-- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection)
+- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
 - Archive comparison (side-by-side diff)
 
 ### 📊 Monitoring & Control

+ 1 - 0
backend/app/api/routes/notification_templates.py

@@ -36,6 +36,7 @@ EVENT_NAMES = {
 
 
 @router.get("", response_model=list[NotificationTemplateResponse])
+@router.get("/", response_model=list[NotificationTemplateResponse])
 async def get_templates(db: AsyncSession = Depends(get_db)):
     """Get all notification templates."""
     result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))

+ 1 - 0
backend/app/api/routes/settings.py

@@ -53,6 +53,7 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
     await db.execute(stmt)
 
 
+@router.get("", response_model=AppSettings)
 @router.get("/", response_model=AppSettings)
 async def get_settings(db: AsyncSession = Depends(get_db)):
     """Get all application settings."""

+ 4 - 2
backend/app/main.py

@@ -2036,9 +2036,11 @@ async def serve_service_worker():
 @app.get("/{full_path:path}")
 async def serve_spa(full_path: str):
     """Serve React app for client-side routing."""
-    # Don't intercept API routes
+    # Don't intercept API routes - raise proper 404 so FastAPI can handle redirects
     if full_path.startswith("api/"):
-        return {"error": "Not found"}
+        from fastapi import HTTPException
+
+        raise HTTPException(status_code=404, detail="Not found")
 
     index_file = app_settings.static_dir / "index.html"
     if index_file.exists():

+ 2 - 1
frontend/src/components/GcodeViewer.tsx

@@ -168,7 +168,8 @@ export function GcodeViewer({
       }
       initRef.current = false;
     };
-  }, [gcodeUrl, colorsKey]); // Use colorsKey instead of filamentColors
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [gcodeUrl, colorsKey]); // Intentionally use colorsKey instead of filamentColors, buildVolume rarely changes
 
   const handleLayerChange = useCallback((layer: number) => {
     if (!previewRef.current) return;

+ 2 - 2
frontend/src/components/KProfilesView.tsx

@@ -711,8 +711,8 @@ export function KProfilesView() {
   const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
   const [bulkDeleteInProgress, setBulkDeleteInProgress] = useState(false);
 
-  // Helper to create unique profile key for selection
-  const getProfileKey = (profile: KProfile) => `${profile.slot_id}_${profile.extruder_id}`;
+  // Helper to create unique profile key for selection - wrapped in useCallback to prevent re-renders
+  const getProfileKey = useCallback((profile: KProfile) => `${profile.slot_id}_${profile.extruder_id}`, []);
 
   // Save nozzle diameter to localStorage when it changes
   useEffect(() => {

+ 4 - 4
frontend/src/components/Layout.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
+import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, Plug, Bug, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
@@ -162,9 +162,9 @@ export function Layout() {
     return () => clearInterval(interval);
   }, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]);
 
-  // Build the unified sidebar items list
-  const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
-  const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
+  // Build the unified sidebar items list - memoized to prevent re-renders
+  const navItemsMap = useMemo(() => new Map(defaultNavItems.map(item => [item.id, item])), []);
+  const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
 
   // Compute the ordered sidebar: include stored order + any new items
   const orderedSidebarIds = (() => {

+ 1 - 1
frontend/src/components/MQTTDebugModal.tsx

@@ -92,7 +92,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
   };
 
   const loggingEnabled = data?.logging_enabled ?? false;
-  const logs = data?.logs ?? [];
+  const logs = useMemo(() => data?.logs ?? [], [data?.logs]);
 
   // Filter logs based on search query and direction filter
   const filteredLogs = useMemo(() => {

+ 2 - 0
frontend/src/components/SpoolmanSettings.tsx

@@ -72,6 +72,7 @@ export function SpoolmanSettings() {
   }, [settings]);
 
   // Auto-save when settings change (after initial load)
+  // Intentionally omit saveMutation and settings from deps to avoid infinite loops
   useEffect(() => {
     if (!isInitialized || !settings) return;
 
@@ -86,6 +87,7 @@ export function SpoolmanSettings() {
       }, 500);
       return () => clearTimeout(timeoutId);
     }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [localEnabled, localUrl, localSyncMode, isInitialized]);
 
   // Save mutation

+ 1 - 1
frontend/src/hooks/useWebSocket.ts

@@ -118,7 +118,7 @@ export function useWebSocket() {
     };
 
     wsRef.current = ws;
-  }, []);
+  }, [processMessageQueue]);
 
   // Throttled printer status update - coalesces rapid updates per printer
   const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {

+ 5 - 2
frontend/src/pages/CameraPage.tsx

@@ -67,12 +67,15 @@ export function CameraPage() {
 
     window.addEventListener('beforeunload', handleBeforeUnload);
 
+    // Store ref value for cleanup - ref may change by cleanup time
+    const imgElement = imgRef.current;
+
     return () => {
       window.removeEventListener('beforeunload', handleBeforeUnload);
 
       // Clear the image source first to stop the stream
-      if (imgRef.current) {
-        imgRef.current.src = '';
+      if (imgElement) {
+        imgElement.src = '';
       }
       // Send stop signal only once
       sendStopOnce();