Browse Source

Archive improvements: list view actions, object count, cross-view highlighting

  Added:
  - Archive list view: edit/delete buttons and context menu with full feature parity
  - Archive object count display on cards (extracted from 3MF metadata)
  - Cross-view archive highlighting: click in calendar/project to highlight in card view
  - Context menu button (⋮) on cards and list rows for easy access
  - Spoolman: clear location when spools are removed from AMS

  Fixed:
  - QR code endpoint 500 error (added qrcode[pil] dependency)
maziggy 4 months ago
parent
commit
e2b2877965

+ 28 - 0
CHANGELOG.md

@@ -2,6 +2,34 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b9] - 2026-01-09
+
+### Added
+- **Archive list view improvements** - Full feature parity with card view:
+  - Edit and delete buttons inline with each row
+  - Three-dot menu button for context menu access
+  - All context menu actions (re-print, compare, add to project, etc.)
+- **Archive object count** - Shows number of printable objects on archive cards:
+  - Displays in stats grid (e.g., "3 objs")
+  - Extracted from 3MF metadata automatically
+- **Cross-view archive highlight** - Click an archive in calendar or project view to highlight it:
+  - Switches to card/list view and scrolls to the archive
+  - Yellow border highlight for 5 seconds
+  - Works in card, list, and calendar views
+- **Context menu visual indicator** - Three-dot button on cards and list items:
+  - Shows on hover (desktop) or always visible (mobile)
+  - Provides quick access to context menu actions
+  - Positioned on left side for easy access
+- **Spoolman location clearing** - When spools are removed from AMS, their location field is now cleared in Spoolman:
+  - Previously, location persisted even after spool removal
+  - Now correctly clears "Printer Name - AMS X Slot Y" when spool is no longer present
+
+### Fixed
+- **QR code endpoint** - Fixed 500 error on archive QR code generation:
+  - Added `qrcode[pil]` to requirements.txt
+  - Improved error handling for missing dependencies
+  - Fixed PIL Image resizing method
+
 ## [0.1.6b8] - 2026-01-08
 
 ### Added

+ 12 - 5
backend/app/api/routes/archives.py

@@ -1464,16 +1464,20 @@ async def get_qrcode(
     db: AsyncSession = Depends(get_db),
 ):
     """Generate a QR code that links to this archive."""
-    import qrcode
+    try:
+        import qrcode
+        from PIL import Image as PILImage
+    except ImportError:
+        raise HTTPException(500, "QR code generation not available - qrcode package not installed")
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     if not archive:
         raise HTTPException(404, "Archive not found")
 
-    # Build URL to archive detail page
+    # Build URL to archive download
     base_url = str(request.base_url).rstrip("/")
-    archive_url = f"{base_url}/archives?id={archive_id}"
+    archive_url = f"{base_url}/api/v1/archives/{archive_id}/download"
 
     # Generate QR code
     qr = qrcode.QRCode(
@@ -1487,13 +1491,16 @@ async def get_qrcode(
 
     img = qr.make_image(fill_color="black", back_color="white")
 
+    # Convert to PIL Image for resizing
+    pil_img = img.get_image()
+
     # Resize if needed
     if size != 200:
-        img = img.resize((size, size))
+        pil_img = pil_img.resize((size, size), PILImage.Resampling.LANCZOS)
 
     # Convert to bytes
     buffer = io.BytesIO()
-    img.save(buffer, format="PNG")
+    pil_img.save(buffer, format="PNG")
     buffer.seek(0)
 
     return Response(

+ 13 - 1
backend/app/schemas/archive.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from pydantic import BaseModel
+from pydantic import BaseModel, model_validator
 
 
 class ArchiveBase(BaseModel):
@@ -45,6 +45,9 @@ class ArchiveResponse(BaseModel):
     duplicates: list[ArchiveDuplicate] | None = None
     duplicate_count: int = 0  # Quick count for list views
 
+    # Object count (computed from extra_data.printable_objects)
+    object_count: int | None = None
+
     print_name: str | None
     print_time_seconds: int | None  # Estimated time from slicer
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
@@ -81,6 +84,15 @@ class ArchiveResponse(BaseModel):
 
     created_at: datetime
 
+    @model_validator(mode="after")
+    def compute_object_count(self) -> "ArchiveResponse":
+        """Compute object_count from extra_data.printable_objects if not set."""
+        if self.object_count is None and self.extra_data:
+            printable_objects = self.extra_data.get("printable_objects")
+            if printable_objects and isinstance(printable_objects, dict):
+                self.object_count = len(printable_objects)
+        return self
+
     class Config:
         from_attributes = True
 

+ 1 - 0
frontend/src/api/client.ts

@@ -204,6 +204,7 @@ export interface Archive {
   source_3mf_path: string | null;
   duplicates: ArchiveDuplicate[] | null;
   duplicate_count: number;
+  object_count: number | null;
   print_name: string | null;
   print_time_seconds: number | null;
   actual_time_seconds: number | null;  // Computed from started_at/completed_at

+ 25 - 6
frontend/src/components/CalendarView.tsx

@@ -6,6 +6,7 @@ import { api } from '../api/client';
 interface CalendarViewProps {
   archives: Archive[];
   onArchiveClick?: (archive: Archive) => void;
+  highlightedArchiveId?: number | null;
 }
 
 function getDaysInMonth(year: number, month: number): number {
@@ -23,11 +24,12 @@ const MONTH_NAMES = [
 
 const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
 
-export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
+export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }: CalendarViewProps) {
   const today = new Date();
   const [currentMonth, setCurrentMonth] = useState(today.getMonth());
   const [currentYear, setCurrentYear] = useState(today.getFullYear());
   const [selectedDate, setSelectedDate] = useState<string | null>(null);
+  const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
 
   // Group archives by date
   const archivesByDate = useMemo(() => {
@@ -79,6 +81,14 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
 
   const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
 
+  // Clear selected archive when date changes
+  const handleDateSelect = (dateKey: string | null) => {
+    if (dateKey !== selectedDate) {
+      setSelectedArchiveId(null);
+    }
+    setSelectedDate(dateKey);
+  };
+
   return (
     <div className="flex flex-col lg:flex-row gap-6">
       {/* Calendar */}
@@ -137,7 +147,7 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
             return (
               <button
                 key={day}
-                onClick={() => setSelectedDate(isSelected ? null : dateKey)}
+                onClick={() => handleDateSelect(isSelected ? null : dateKey)}
                 className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${
                   isSelected
                     ? 'bg-bambu-green text-white'
@@ -216,11 +226,19 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
             </h3>
             {selectedArchives.length > 0 ? (
               <div className="space-y-2 max-h-96 overflow-y-auto">
-                {selectedArchives.map(archive => (
+                {selectedArchives.map(archive => {
+                  const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
+                  return (
                   <button
                     key={archive.id}
-                    onClick={() => onArchiveClick?.(archive)}
-                    className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-left"
+                    onClick={() => {
+                      setSelectedArchiveId(archive.id);
+                      onArchiveClick?.(archive);
+                    }}
+                    className={`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${
+                      !isHighlighted ? 'hover:bg-bambu-dark-tertiary' : ''
+                    }`}
+                    style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
                   >
                     {archive.thumbnail_path ? (
                       <img
@@ -255,7 +273,8 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
                       </div>
                     </div>
                   </button>
-                ))}
+                  );
+                })}
               </div>
             ) : (
               <p className="text-sm text-bambu-gray">No prints on this day</p>

+ 4 - 3
frontend/src/components/Card.tsx

@@ -1,18 +1,19 @@
-import type { ReactNode, MouseEvent } from 'react';
+import type { ReactNode, MouseEvent, HTMLAttributes } from 'react';
 
-interface CardProps {
+interface CardProps extends HTMLAttributes<HTMLDivElement> {
   children: ReactNode;
   className?: string;
   onClick?: (e: MouseEvent) => void;
   onContextMenu?: (e: MouseEvent) => void;
 }
 
-export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
+export function Card({ children, className = '', onClick, onContextMenu, ...rest }: CardProps) {
   return (
     <div
       className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
       onClick={onClick}
       onContextMenu={onContextMenu}
+      {...rest}
     >
       {children}
     </div>

File diff suppressed because it is too large
+ 809 - 145
frontend/src/pages/ArchivesPage.tsx


+ 3 - 0
requirements.txt

@@ -31,6 +31,9 @@ pywebpush>=2.0.0
 python-multipart>=0.0.6
 aiofiles>=23.0.0
 
+# QR Code generation
+qrcode[pil]>=7.4.0
+
 # System monitoring
 psutil>=6.0.0
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BEtulymk.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DIdbNfsf.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-OrUoRy29.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BstVVW2U.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DIdbNfsf.css">
+    <script type="module" crossorigin src="/assets/index-OrUoRy29.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BEtulymk.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff