diff --git a/CHANGELOG.md b/CHANGELOG.md index 0413f493c..50da35f07 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index e0669f9f2..bd6e93ccb 100644 --- a/README.md +++ b/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 diff --git a/backend/app/api/routes/notification_templates.py b/backend/app/api/routes/notification_templates.py index 337a810e8..ea5d20819 100644 --- a/backend/app/api/routes/notification_templates.py +++ b/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)) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index bb386e202..a6a010163 100644 --- a/backend/app/api/routes/settings.py +++ b/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.""" diff --git a/backend/app/main.py b/backend/app/main.py index d6b3c44a3..dcd80847f 100644 --- a/backend/app/main.py +++ b/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(): diff --git a/frontend/src/components/GcodeViewer.tsx b/frontend/src/components/GcodeViewer.tsx index 32d5c726a..64a61e65a 100644 --- a/frontend/src/components/GcodeViewer.tsx +++ b/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; diff --git a/frontend/src/components/KProfilesView.tsx b/frontend/src/components/KProfilesView.tsx index 2b99e6449..71b253556 100644 --- a/frontend/src/components/KProfilesView.tsx +++ b/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(() => { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2eed4f95a..ff76eb9fa 100644 --- a/frontend/src/components/Layout.tsx +++ b/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 = (() => { diff --git a/frontend/src/components/MQTTDebugModal.tsx b/frontend/src/components/MQTTDebugModal.tsx index cac04bc67..46dc7a07a 100644 --- a/frontend/src/components/MQTTDebugModal.tsx +++ b/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(() => { diff --git a/frontend/src/components/SpoolmanSettings.tsx b/frontend/src/components/SpoolmanSettings.tsx index a02651d97..e444a224b 100644 --- a/frontend/src/components/SpoolmanSettings.tsx +++ b/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 diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index f3af1d119..fcadfee43 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/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) => { diff --git a/frontend/src/pages/CameraPage.tsx b/frontend/src/pages/CameraPage.tsx index 823f7d626..e2ea68923 100644 --- a/frontend/src/pages/CameraPage.tsx +++ b/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();