Przeglądaj źródła

- Project page enhancements and bug fixes
- Add filament color swatches to project cards showing colors from assigned archives
- Add "Hide done" toggle to filter completed BOM items
- Include projects in backup/restore (with BOM items and attachments)
- Add file type validation for project attachments
- Enhance project card design with gradients, shadows, and glow effects
- Improve layout spacing on project list and detail pages
- Replace browser confirm dialogs with styled confirmation modals
- Fix attachment uploads not persisting (SQLAlchemy JSON column mutation)

maziggy 5 miesięcy temu
rodzic
commit
db5cb86d3a

+ 8 - 0
CHANGELOG.md

@@ -8,13 +8,21 @@ All notable changes to Bambuddy will be documented in this file.
 - **Docker printer discovery** - Subnet scanning for discovering printers when running in Docker with `network_mode: host`. Automatically detects Docker environment and shows subnet input field in Add Printer dialog.
 - **Printer model mapping** - Discovery now shows friendly model names (X1C, H2D, P1S) instead of raw SSDP codes (BL-P001, O1D, C11).
 - **Discovery API tests** - Comprehensive test coverage for discovery endpoints.
+- **Project filament colors** - Project cards now display filament color swatches from assigned archives.
+- **BOM filter** - Hide completed BOM items with "Hide done" toggle on project detail page.
+- **Projects in backup/restore** - Projects, BOM items, and attachments now included in database backup/restore.
+- **Attachment file validation** - File type validation for project attachments (images, documents, 3D files, archives, scripts, configs).
 
 ### Changed
 - **GitHub issue template** - Added mandatory printer firmware version field and LAN-only mode checkbox for better bug reports.
 - **Docker compose** - Clearer comments explaining `network_mode: host` requirement for printer discovery and camera streaming.
+- **Project card design** - Enhanced visual polish with gradients, shadows, and glow effects on hover.
+- **Project page layout** - Improved spacing and padding on project list and detail pages.
+- **Delete confirmations** - Replaced browser confirm dialogs with styled confirmation modals.
 
 ### Fixed
 - **Notification module** - Fixed bug where notifications were sent even when printer was offline.
+- **Attachment uploads** - Fixed file attachments not persisting due to SQLAlchemy JSON column mutation detection.
 
 ## [0.1.5] - 2025-12-19
 

+ 0 - 8
README.md

@@ -97,14 +97,6 @@
 - Print time accuracy stats
 - File manager for printer storage
 
-### 🎛️ Printer Control
-- AMS/AMS-HT temperature & humidity monitoring
-- Chamber temperature & light control
-- Speed profiles & fan controls
-- AI detection modules (spaghetti, first layer)
-- Automated calibration (bed level, vibration)
-- Dual nozzle support
-
 </td>
 </tr>
 </table>

+ 129 - 32
backend/app/api/routes/projects.py

@@ -98,7 +98,7 @@ async def compute_project_stats(db: AsyncSession, project_id: int, target_count:
     bom_result = await db.execute(
         select(
             func.count(ProjectBOMItem.id).label("total"),
-            func.sum(case((ProjectBOMItem.quantity_printed >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
+            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
                 "completed"
             ),
         ).where(ProjectBOMItem.project_id == project_id)
@@ -183,6 +183,8 @@ async def list_projects(
                 print_name=a.print_name,
                 thumbnail_path=a.thumbnail_path,
                 status=a.status,
+                filament_type=a.filament_type,
+                filament_color=a.filament_color,
             )
             for a in archives
         ]
@@ -342,9 +344,11 @@ async def create_project_from_template(
             project_id=project.id,
             name=item.name,
             quantity_needed=item.quantity_needed,
-            quantity_printed=0,
+            quantity_acquired=0,
+            unit_price=item.unit_price,
+            sourcing_url=item.sourcing_url,
             stl_filename=item.stl_filename,
-            notes=item.notes,
+            remarks=item.remarks,
             sort_order=item.sort_order,
         )
         db.add(new_item)
@@ -691,6 +695,63 @@ def get_project_attachments_dir(project_id: int) -> Path:
     return base_dir / "projects" / str(project_id) / "attachments"
 
 
+# Allowed file extensions for attachments
+ALLOWED_ATTACHMENT_EXTENSIONS = {
+    # Images
+    ".jpg",
+    ".jpeg",
+    ".png",
+    ".gif",
+    ".webp",
+    ".svg",
+    ".bmp",
+    ".ico",
+    # Documents
+    ".pdf",
+    ".doc",
+    ".docx",
+    ".xls",
+    ".xlsx",
+    ".ppt",
+    ".pptx",
+    ".odt",
+    ".ods",
+    ".odp",
+    ".txt",
+    ".rtf",
+    ".csv",
+    ".md",
+    # 3D/CAD files
+    ".stl",
+    ".obj",
+    ".3mf",
+    ".step",
+    ".stp",
+    ".iges",
+    ".igs",
+    ".f3d",
+    ".scad",
+    # Archives
+    ".zip",
+    ".rar",
+    ".7z",
+    ".tar",
+    ".gz",
+    # Code/scripts (for Klipper macros, scripts, etc.)
+    ".py",
+    ".sh",
+    ".cfg",
+    ".conf",
+    ".gcode",
+    ".ini",
+    # Other common formats
+    ".json",
+    ".xml",
+    ".yaml",
+    ".yml",
+}
+
+
 @router.post("/{project_id}/attachments")
 async def upload_attachment(
     project_id: int,
@@ -698,19 +759,28 @@ async def upload_attachment(
     db: AsyncSession = Depends(get_db),
 ):
     """Upload an attachment to a project."""
+    logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
+
     # Verify project exists
     result = await db.execute(select(Project).where(Project.id == project_id))
     project = result.scalar_one_or_none()
     if not project:
         raise HTTPException(status_code=404, detail="Project not found")
 
+    # Validate file extension
+    original_name = file.filename or "unknown"
+    ext = os.path.splitext(original_name)[1].lower()
+    if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
+        )
+
     # Create attachments directory
     attachments_dir = get_project_attachments_dir(project_id)
     attachments_dir.mkdir(parents=True, exist_ok=True)
 
     # Generate unique filename
-    original_name = file.filename or "unknown"
-    ext = os.path.splitext(original_name)[1]
     unique_filename = f"{uuid.uuid4().hex}{ext}"
     file_path = attachments_dir / unique_filename
 
@@ -719,30 +789,43 @@ async def upload_attachment(
         with open(file_path, "wb") as f:
             content = await file.read()
             f.write(content)
+        logger.info(f"=== FILE SAVED: {file_path}, size: {len(content)} ===")
     except Exception as e:
         logger.error(f"Failed to save attachment: {e}")
         raise HTTPException(status_code=500, detail="Failed to save attachment")
 
     # Update project attachments JSON
-    attachments = project.attachments or []
-    attachments.append(
-        {
-            "filename": unique_filename,
-            "original_name": original_name,
-            "size": len(content),
-            "uploaded_at": datetime.now().isoformat(),
-        }
-    )
+    attachments = list(project.attachments or [])
+    new_attachment = {
+        "filename": unique_filename,
+        "original_name": original_name,
+        "size": len(content),
+        "uploaded_at": datetime.now().isoformat(),
+    }
+    attachments.append(new_attachment)
+
+    # Simple ORM update
     project.attachments = attachments
+    db.add(project)  # Explicitly add to session
+
+    logger.info(f"=== BEFORE COMMIT: {len(attachments)} attachments ===")
 
     await db.flush()
-    await db.refresh(project)
+    await db.commit()
+
+    logger.info("=== AFTER COMMIT ===")
+
+    # Verify by re-querying
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    fresh_project = result.scalar_one()
+
+    logger.info(f"=== VERIFIED: {len(fresh_project.attachments or [])} attachments ===")
 
     return {
         "status": "success",
         "filename": unique_filename,
         "original_name": original_name,
-        "attachments": project.attachments,
+        "attachments": fresh_project.attachments,
     }
 
 
@@ -854,13 +937,15 @@ async def list_bom_items(
                 project_id=item.project_id,
                 name=item.name,
                 quantity_needed=item.quantity_needed,
-                quantity_printed=item.quantity_printed,
+                quantity_acquired=item.quantity_acquired,
+                unit_price=item.unit_price,
+                sourcing_url=item.sourcing_url,
                 archive_id=item.archive_id,
                 archive_name=archive_name,
                 stl_filename=item.stl_filename,
-                notes=item.notes,
+                remarks=item.remarks,
                 sort_order=item.sort_order,
-                is_complete=item.quantity_printed >= item.quantity_needed,
+                is_complete=item.quantity_acquired >= item.quantity_needed,
                 created_at=item.created_at,
                 updated_at=item.updated_at,
             )
@@ -891,9 +976,11 @@ async def create_bom_item(
         project_id=project_id,
         name=data.name,
         quantity_needed=data.quantity_needed,
+        unit_price=data.unit_price,
+        sourcing_url=data.sourcing_url,
         archive_id=data.archive_id,
         stl_filename=data.stl_filename,
-        notes=data.notes,
+        remarks=data.remarks,
         sort_order=max_order + 1,
     )
     db.add(item)
@@ -911,13 +998,15 @@ async def create_bom_item(
         project_id=item.project_id,
         name=item.name,
         quantity_needed=item.quantity_needed,
-        quantity_printed=item.quantity_printed,
+        quantity_acquired=item.quantity_acquired,
+        unit_price=item.unit_price,
+        sourcing_url=item.sourcing_url,
         archive_id=item.archive_id,
         archive_name=archive_name,
         stl_filename=item.stl_filename,
-        notes=item.notes,
+        remarks=item.remarks,
         sort_order=item.sort_order,
-        is_complete=item.quantity_printed >= item.quantity_needed,
+        is_complete=item.quantity_acquired >= item.quantity_needed,
         created_at=item.created_at,
         updated_at=item.updated_at,
     )
@@ -946,14 +1035,18 @@ async def update_bom_item(
         item.name = data.name
     if data.quantity_needed is not None:
         item.quantity_needed = data.quantity_needed
-    if data.quantity_printed is not None:
-        item.quantity_printed = data.quantity_printed
+    if data.quantity_acquired is not None:
+        item.quantity_acquired = data.quantity_acquired
+    if data.unit_price is not None:
+        item.unit_price = data.unit_price if data.unit_price != 0 else None
+    if data.sourcing_url is not None:
+        item.sourcing_url = data.sourcing_url if data.sourcing_url else None
     if data.archive_id is not None:
         item.archive_id = data.archive_id if data.archive_id != 0 else None
     if data.stl_filename is not None:
         item.stl_filename = data.stl_filename if data.stl_filename else None
-    if data.notes is not None:
-        item.notes = data.notes if data.notes else None
+    if data.remarks is not None:
+        item.remarks = data.remarks if data.remarks else None
 
     await db.flush()
     await db.refresh(item)
@@ -969,13 +1062,15 @@ async def update_bom_item(
         project_id=item.project_id,
         name=item.name,
         quantity_needed=item.quantity_needed,
-        quantity_printed=item.quantity_printed,
+        quantity_acquired=item.quantity_acquired,
+        unit_price=item.unit_price,
+        sourcing_url=item.sourcing_url,
         archive_id=item.archive_id,
         archive_name=archive_name,
         stl_filename=item.stl_filename,
-        notes=item.notes,
+        remarks=item.remarks,
         sort_order=item.sort_order,
-        is_complete=item.quantity_printed >= item.quantity_needed,
+        is_complete=item.quantity_acquired >= item.quantity_needed,
         created_at=item.created_at,
         updated_at=item.updated_at,
     )
@@ -1041,9 +1136,11 @@ async def create_template_from_project(
             project_id=template.id,
             name=item.name,
             quantity_needed=item.quantity_needed,
-            quantity_printed=0,
+            quantity_acquired=0,
+            unit_price=item.unit_price,
+            sourcing_url=item.sourcing_url,
             stl_filename=item.stl_filename,
-            notes=item.notes,
+            remarks=item.remarks,
             sort_order=item.sort_order,
         )
         db.add(new_item)

+ 280 - 115
backend/app/api/routes/settings.py

@@ -3,28 +3,28 @@ import json
 import zipfile
 from datetime import datetime
 from pathlib import Path
-from typing import Optional
 
-from fastapi import APIRouter, Depends, UploadFile, File, Query
+from fastapi import APIRouter, Depends, File, Query, UploadFile
 from fastapi.responses import JSONResponse, StreamingResponse
-from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
-from backend.app.models.settings import Settings
+from backend.app.models.archive import PrintArchive
+from backend.app.models.external_link import ExternalLink
+from backend.app.models.filament import Filament
+from backend.app.models.maintenance import MaintenanceType
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
-from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.printer import Printer
-from backend.app.models.filament import Filament
-from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
-from backend.app.models.archive import PrintArchive
-from backend.app.models.external_link import ExternalLink
+from backend.app.models.project import Project
+from backend.app.models.project_bom import ProjectBOMItem
+from backend.app.models.settings import Settings
+from backend.app.models.smart_plug import SmartPlug
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.services.printer_manager import printer_manager
-from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
-
+from backend.app.services.spoolman import init_spoolman_client
 
 router = APIRouter(prefix="/settings", tags=["settings"])
 
@@ -63,7 +63,14 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
         if setting.key in settings_dict:
             # Parse the value based on the expected type
-            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates", "telemetry_enabled"]:
+            if setting.key in [
+                "auto_archive",
+                "save_thumbnails",
+                "capture_finish_photo",
+                "spoolman_enabled",
+                "check_updates",
+                "telemetry_enabled",
+            ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
                 settings_dict[setting.key] = float(setting.value)
@@ -173,6 +180,7 @@ async def export_backup(
     include_filaments: bool = Query(False, description="Include filament inventory"),
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
     include_archives: bool = Query(False, description="Include print archive metadata"),
+    include_projects: bool = Query(False, description="Include projects with BOM items"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
 ):
     """Export selected data as JSON backup."""
@@ -195,27 +203,29 @@ async def export_backup(
         providers = result.scalars().all()
         backup["notification_providers"] = []
         for p in providers:
-            backup["notification_providers"].append({
-                "name": p.name,
-                "provider_type": p.provider_type,
-                "enabled": p.enabled,
-                "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
-                "on_print_start": p.on_print_start,
-                "on_print_complete": p.on_print_complete,
-                "on_print_failed": p.on_print_failed,
-                "on_print_stopped": p.on_print_stopped,
-                "on_print_progress": p.on_print_progress,
-                "on_printer_offline": p.on_printer_offline,
-                "on_printer_error": p.on_printer_error,
-                "on_filament_low": p.on_filament_low,
-                "on_maintenance_due": p.on_maintenance_due,
-                "quiet_hours_enabled": p.quiet_hours_enabled,
-                "quiet_hours_start": p.quiet_hours_start,
-                "quiet_hours_end": p.quiet_hours_end,
-                "daily_digest_enabled": getattr(p, 'daily_digest_enabled', False),
-                "daily_digest_time": getattr(p, 'daily_digest_time', None),
-                "printer_id": getattr(p, 'printer_id', None),
-            })
+            backup["notification_providers"].append(
+                {
+                    "name": p.name,
+                    "provider_type": p.provider_type,
+                    "enabled": p.enabled,
+                    "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
+                    "on_print_start": p.on_print_start,
+                    "on_print_complete": p.on_print_complete,
+                    "on_print_failed": p.on_print_failed,
+                    "on_print_stopped": p.on_print_stopped,
+                    "on_print_progress": p.on_print_progress,
+                    "on_printer_offline": p.on_printer_offline,
+                    "on_printer_error": p.on_printer_error,
+                    "on_filament_low": p.on_filament_low,
+                    "on_maintenance_due": p.on_maintenance_due,
+                    "quiet_hours_enabled": p.quiet_hours_enabled,
+                    "quiet_hours_start": p.quiet_hours_start,
+                    "quiet_hours_end": p.quiet_hours_end,
+                    "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
+                    "daily_digest_time": getattr(p, "daily_digest_time", None),
+                    "printer_id": getattr(p, "printer_id", None),
+                }
+            )
         backup["included"].append("notification_providers")
 
     # Notification templates
@@ -224,13 +234,15 @@ async def export_backup(
         templates = result.scalars().all()
         backup["notification_templates"] = []
         for t in templates:
-            backup["notification_templates"].append({
-                "event_type": t.event_type,
-                "name": t.name,
-                "title_template": t.title_template,
-                "body_template": t.body_template,
-                "is_default": t.is_default,
-            })
+            backup["notification_templates"].append(
+                {
+                    "event_type": t.event_type,
+                    "name": t.name,
+                    "title_template": t.title_template,
+                    "body_template": t.body_template,
+                    "is_default": t.is_default,
+                }
+            )
         backup["included"].append("notification_templates")
 
     # Smart plugs
@@ -239,25 +251,27 @@ async def export_backup(
         plugs = result.scalars().all()
         backup["smart_plugs"] = []
         for plug in plugs:
-            backup["smart_plugs"].append({
-                "name": plug.name,
-                "ip_address": plug.ip_address,
-                "printer_id": plug.printer_id,
-                "enabled": plug.enabled,
-                "auto_on": plug.auto_on,
-                "auto_off": plug.auto_off,
-                "off_delay_mode": plug.off_delay_mode,
-                "off_delay_minutes": plug.off_delay_minutes,
-                "off_temp_threshold": plug.off_temp_threshold,
-                "username": plug.username,
-                "password": plug.password,
-                "power_alert_enabled": plug.power_alert_enabled,
-                "power_alert_high": plug.power_alert_high,
-                "power_alert_low": plug.power_alert_low,
-                "schedule_enabled": plug.schedule_enabled,
-                "schedule_on_time": plug.schedule_on_time,
-                "schedule_off_time": plug.schedule_off_time,
-            })
+            backup["smart_plugs"].append(
+                {
+                    "name": plug.name,
+                    "ip_address": plug.ip_address,
+                    "printer_id": plug.printer_id,
+                    "enabled": plug.enabled,
+                    "auto_on": plug.auto_on,
+                    "auto_off": plug.auto_off,
+                    "off_delay_mode": plug.off_delay_mode,
+                    "off_delay_minutes": plug.off_delay_minutes,
+                    "off_temp_threshold": plug.off_temp_threshold,
+                    "username": plug.username,
+                    "password": plug.password,
+                    "power_alert_enabled": plug.power_alert_enabled,
+                    "power_alert_high": plug.power_alert_high,
+                    "power_alert_low": plug.power_alert_low,
+                    "schedule_enabled": plug.schedule_enabled,
+                    "schedule_on_time": plug.schedule_on_time,
+                    "schedule_off_time": plug.schedule_off_time,
+                }
+            )
         backup["included"].append("smart_plugs")
 
     # External links
@@ -312,21 +326,23 @@ async def export_backup(
         filaments = result.scalars().all()
         backup["filaments"] = []
         for f in filaments:
-            backup["filaments"].append({
-                "name": f.name,
-                "type": f.type,
-                "brand": f.brand,
-                "color": f.color,
-                "color_hex": f.color_hex,
-                "cost_per_kg": f.cost_per_kg,
-                "spool_weight_g": f.spool_weight_g,
-                "currency": f.currency,
-                "density": f.density,
-                "print_temp_min": f.print_temp_min,
-                "print_temp_max": f.print_temp_max,
-                "bed_temp_min": f.bed_temp_min,
-                "bed_temp_max": f.bed_temp_max,
-            })
+            backup["filaments"].append(
+                {
+                    "name": f.name,
+                    "type": f.type,
+                    "brand": f.brand,
+                    "color": f.color,
+                    "color_hex": f.color_hex,
+                    "cost_per_kg": f.cost_per_kg,
+                    "spool_weight_g": f.spool_weight_g,
+                    "currency": f.currency,
+                    "density": f.density,
+                    "print_temp_min": f.print_temp_min,
+                    "print_temp_max": f.print_temp_max,
+                    "bed_temp_min": f.bed_temp_min,
+                    "bed_temp_max": f.bed_temp_max,
+                }
+            )
         backup["included"].append("filaments")
 
     # Maintenance types and records
@@ -336,14 +352,16 @@ async def export_backup(
         types = result.scalars().all()
         backup["maintenance_types"] = []
         for mt in types:
-            backup["maintenance_types"].append({
-                "name": mt.name,
-                "description": mt.description,
-                "default_interval_hours": mt.default_interval_hours,
-                "interval_type": mt.interval_type,
-                "icon": mt.icon,
-                "is_system": mt.is_system,
-            })
+            backup["maintenance_types"].append(
+                {
+                    "name": mt.name,
+                    "description": mt.description,
+                    "default_interval_hours": mt.default_interval_hours,
+                    "interval_type": mt.interval_type,
+                    "icon": mt.icon,
+                    "is_system": mt.is_system,
+                }
+            )
         backup["included"].append("maintenance_types")
 
     # Collect files for ZIP (icons + archives)
@@ -365,9 +383,17 @@ async def export_backup(
         backup["archives"] = []
         base_dir = app_settings.base_dir
 
+        # Build project ID to name mapping for archive export
+        project_id_to_name: dict[int, str] = {}
+        if include_projects:
+            proj_result = await db.execute(select(Project))
+            for proj in proj_result.scalars().all():
+                project_id_to_name[proj.id] = proj.name
+
         for a in archives:
             archive_data = {
                 "filename": a.filename,
+                "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
                 "file_size": a.file_size,
                 "content_hash": a.content_hash,
                 "print_name": a.print_name,
@@ -432,10 +458,61 @@ async def export_backup(
             backup["archives"].append(archive_data)
         backup["included"].append("archives")
 
+    # Projects with BOM items
+    if include_projects:
+        result = await db.execute(select(Project))
+        projects = result.scalars().all()
+        backup["projects"] = []
+
+        for p in projects:
+            # Get BOM items for this project
+            bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id))
+            bom_items = bom_result.scalars().all()
+
+            project_data = {
+                "name": p.name,
+                "description": p.description,
+                "color": p.color,
+                "status": p.status,
+                "target_count": p.target_count,
+                "notes": p.notes,
+                "tags": p.tags,
+                "due_date": p.due_date.isoformat() if p.due_date else None,
+                "priority": p.priority,
+                "budget": p.budget,
+                "is_template": p.is_template,
+                "bom_items": [
+                    {
+                        "name": item.name,
+                        "quantity_needed": item.quantity_needed,
+                        "quantity_acquired": item.quantity_acquired,
+                        "unit_price": item.unit_price,
+                        "sourcing_url": item.sourcing_url,
+                        "stl_filename": item.stl_filename,
+                        "remarks": item.remarks,
+                        "sort_order": item.sort_order,
+                    }
+                    for item in bom_items
+                ],
+            }
+
+            # Include attachment files for ZIP
+            if p.attachments:
+                project_data["attachments"] = p.attachments
+                attachments_dir = base_dir / "projects" / str(p.id) / "attachments"
+                for att in p.attachments:
+                    att_path = attachments_dir / att.get("filename", "")
+                    if att_path.exists():
+                        zip_path = f"projects/{p.id}/attachments/{att['filename']}"
+                        backup_files.append((zip_path, att_path))
+
+            backup["projects"].append(project_data)
+        backup["included"].append("projects")
+
     # If there are files to include (icons or archives), create ZIP file
     if backup_files:
         zip_buffer = io.BytesIO()
-        with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
             # Add backup.json
             zf.writestr("backup.json", json.dumps(backup, indent=2))
 
@@ -454,7 +531,7 @@ async def export_backup(
         return StreamingResponse(
             zip_buffer,
             media_type="application/zip",
-            headers={"Content-Disposition": f"attachment; filename={filename}"}
+            headers={"Content-Disposition": f"attachment; filename={filename}"},
         )
 
     # Otherwise return JSON
@@ -462,7 +539,7 @@ async def export_backup(
         content=backup,
         headers={
             "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
-        }
+        },
     )
 
 
@@ -479,27 +556,27 @@ async def import_backup(
         files_restored = 0
 
         # Check if it's a ZIP file
-        if file.filename and file.filename.endswith('.zip'):
+        if file.filename and file.filename.endswith(".zip"):
             try:
                 zip_buffer = io.BytesIO(content)
-                with zipfile.ZipFile(zip_buffer, 'r') as zf:
+                with zipfile.ZipFile(zip_buffer, "r") as zf:
                     # Extract backup.json
-                    if 'backup.json' not in zf.namelist():
+                    if "backup.json" not in zf.namelist():
                         return {"success": False, "message": "Invalid ZIP: missing backup.json"}
 
-                    backup_content = zf.read('backup.json')
+                    backup_content = zf.read("backup.json")
                     backup = json.loads(backup_content.decode("utf-8"))
 
                     # Extract all other files to base_dir
                     for zip_path in zf.namelist():
-                        if zip_path == 'backup.json':
+                        if zip_path == "backup.json":
                             continue
                         # Ensure path is safe (no path traversal)
-                        if '..' in zip_path or zip_path.startswith('/'):
+                        if ".." in zip_path or zip_path.startswith("/"):
                             continue
                         target_path = base_dir / zip_path
                         target_path.parent.mkdir(parents=True, exist_ok=True)
-                        with zf.open(zip_path) as src, open(target_path, 'wb') as dst:
+                        with zf.open(zip_path) as src, open(target_path, "wb") as dst:
                             dst.write(src.read())
                             files_restored += 1
             except zipfile.BadZipFile:
@@ -520,6 +597,7 @@ async def import_backup(
         "printers": 0,
         "filaments": 0,
         "maintenance_types": 0,
+        "projects": 0,
     }
     skipped = {
         "settings": 0,
@@ -531,6 +609,7 @@ async def import_backup(
         "filaments": 0,
         "maintenance_types": 0,
         "archives": 0,
+        "projects": 0,
     }
     skipped_details = {
         "notification_providers": [],
@@ -540,6 +619,7 @@ async def import_backup(
         "filaments": [],
         "maintenance_types": [],
         "archives": [],
+        "projects": [],
     }
 
     # Restore settings (always overwrites)
@@ -616,9 +696,7 @@ async def import_backup(
     if "notification_templates" in backup:
         for template_data in backup["notification_templates"]:
             result = await db.execute(
-                select(NotificationTemplate).where(
-                    NotificationTemplate.event_type == template_data["event_type"]
-                )
+                select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"])
             )
             existing = result.scalar_one_or_none()
             if existing:
@@ -641,9 +719,7 @@ async def import_backup(
     # Restore smart plugs (skip or overwrite duplicates by IP)
     if "smart_plugs" in backup:
         for plug_data in backup["smart_plugs"]:
-            result = await db.execute(
-                select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
-            )
+            result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
             existing = result.scalar_one_or_none()
             if existing:
                 if overwrite:
@@ -697,10 +773,7 @@ async def import_backup(
 
         for link_data in backup["external_links"]:
             result = await db.execute(
-                select(ExternalLink).where(
-                    ExternalLink.name == link_data["name"],
-                    ExternalLink.url == link_data["url"]
-                )
+                select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"])
             )
             existing = result.scalar_one_or_none()
             if existing:
@@ -728,9 +801,7 @@ async def import_backup(
     # Restore printers (skip or overwrite duplicates by serial_number)
     if "printers" in backup:
         for printer_data in backup["printers"]:
-            result = await db.execute(
-                select(Printer).where(Printer.serial_number == printer_data["serial_number"])
-            )
+            result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
             existing = result.scalar_one_or_none()
             if existing:
                 if overwrite:
@@ -805,7 +876,9 @@ async def import_backup(
                     restored["filaments"] += 1
                 else:
                     skipped["filaments"] += 1
-                    skipped_details["filaments"].append(f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})")
+                    skipped_details["filaments"].append(
+                        f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})"
+                    )
             else:
                 filament = Filament(
                     name=filament_data["name"],
@@ -828,9 +901,7 @@ async def import_backup(
     # Restore maintenance types (skip or overwrite duplicates by name)
     if "maintenance_types" in backup:
         for mt_data in backup["maintenance_types"]:
-            result = await db.execute(
-                select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])
-            )
+            result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"]))
             existing = result.scalar_one_or_none()
             if existing:
                 if overwrite:
@@ -861,9 +932,7 @@ async def import_backup(
             # Skip if no content_hash or already exists
             content_hash = archive_data.get("content_hash")
             if content_hash:
-                result = await db.execute(
-                    select(PrintArchive).where(PrintArchive.content_hash == content_hash)
-                )
+                result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
                 existing = result.scalar_one_or_none()
                 if existing:
                     skipped["archives"] += 1
@@ -907,15 +976,111 @@ async def import_backup(
                 db.add(archive)
                 restored["archives"] = restored.get("archives", 0) + 1
 
+    # Restore projects (skip or overwrite duplicates by name)
+    if "projects" in backup:
+        for project_data in backup["projects"]:
+            result = await db.execute(select(Project).where(Project.name == project_data["name"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    # Update existing project
+                    existing.description = project_data.get("description")
+                    existing.color = project_data.get("color")
+                    existing.status = project_data.get("status", "active")
+                    existing.target_count = project_data.get("target_count")
+                    existing.notes = project_data.get("notes")
+                    existing.tags = project_data.get("tags")
+                    existing.priority = project_data.get("priority", "normal")
+                    existing.budget = project_data.get("budget")
+                    existing.is_template = project_data.get("is_template", False)
+                    existing.attachments = project_data.get("attachments")
+                    if project_data.get("due_date"):
+                        existing.due_date = datetime.fromisoformat(project_data["due_date"])
+
+                    # Delete existing BOM items and re-add
+                    await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id))
+                    for bom_data in project_data.get("bom_items", []):
+                        bom_item = ProjectBOMItem(
+                            project_id=existing.id,
+                            name=bom_data["name"],
+                            quantity_needed=bom_data.get("quantity_needed", 1),
+                            quantity_acquired=bom_data.get("quantity_acquired", 0),
+                            unit_price=bom_data.get("unit_price"),
+                            sourcing_url=bom_data.get("sourcing_url"),
+                            stl_filename=bom_data.get("stl_filename"),
+                            remarks=bom_data.get("remarks"),
+                            sort_order=bom_data.get("sort_order", 0),
+                        )
+                        db.add(bom_item)
+
+                    restored["projects"] += 1
+                else:
+                    skipped["projects"] += 1
+                    skipped_details["projects"].append(project_data["name"])
+            else:
+                # Create new project
+                project = Project(
+                    name=project_data["name"],
+                    description=project_data.get("description"),
+                    color=project_data.get("color"),
+                    status=project_data.get("status", "active"),
+                    target_count=project_data.get("target_count"),
+                    notes=project_data.get("notes"),
+                    tags=project_data.get("tags"),
+                    priority=project_data.get("priority", "normal"),
+                    budget=project_data.get("budget"),
+                    is_template=project_data.get("is_template", False),
+                    attachments=project_data.get("attachments"),
+                )
+                if project_data.get("due_date"):
+                    project.due_date = datetime.fromisoformat(project_data["due_date"])
+
+                db.add(project)
+                await db.flush()  # Get the project ID
+
+                # Add BOM items
+                for bom_data in project_data.get("bom_items", []):
+                    bom_item = ProjectBOMItem(
+                        project_id=project.id,
+                        name=bom_data["name"],
+                        quantity_needed=bom_data.get("quantity_needed", 1),
+                        quantity_acquired=bom_data.get("quantity_acquired", 0),
+                        unit_price=bom_data.get("unit_price"),
+                        sourcing_url=bom_data.get("sourcing_url"),
+                        stl_filename=bom_data.get("stl_filename"),
+                        remarks=bom_data.get("remarks"),
+                        sort_order=bom_data.get("sort_order", 0),
+                    )
+                    db.add(bom_item)
+
+                restored["projects"] += 1
+
+    # Link archives to projects by name (after both are restored)
+    if "archives" in backup and "projects" in backup:
+        # Build project name to ID mapping
+        proj_result = await db.execute(select(Project))
+        project_name_to_id: dict[str, int] = {}
+        for proj in proj_result.scalars().all():
+            project_name_to_id[proj.name] = proj.id
+
+        # Update archives with project_id
+        for archive_data in backup["archives"]:
+            project_name = archive_data.get("project_name")
+            if project_name and project_name in project_name_to_id:
+                content_hash = archive_data.get("content_hash")
+                if content_hash:
+                    result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
+                    archive = result.scalar_one_or_none()
+                    if archive:
+                        archive.project_id = project_name_to_id[project_name]
+
     await db.commit()
 
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
     # This ensures connections are re-established after restore, even if printers were skipped
     if "printers" in backup:
         # Need fresh query after commit to get proper IDs for newly created printers
-        result = await db.execute(
-            select(Printer).where(Printer.is_active == True)
-        )
+        result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
         active_printers = result.scalars().all()
         for printer in active_printers:
             # This will disconnect existing connection (if any) and reconnect

+ 24 - 0
backend/app/core/database.py

@@ -335,6 +335,30 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Rename quantity_printed to quantity_acquired in project_bom_items
+    try:
+        await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired"))
+    except Exception:
+        pass
+
+    # Migration: Add unit_price column to project_bom_items
+    try:
+        await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN unit_price REAL"))
+    except Exception:
+        pass
+
+    # Migration: Add sourcing_url column to project_bom_items
+    try:
+        await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)"))
+    except Exception:
+        pass
+
+    # Migration: Rename notes to remarks in project_bom_items
+    try:
+        await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 15 - 7
backend/app/models/project_bom.py

@@ -1,13 +1,17 @@
 from datetime import datetime
 
-from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
 
 class ProjectBOMItem(Base):
-    """Bill of Materials item for a project."""
+    """Bill of Materials item for a project.
+
+    Tracks sourced/purchased parts (hardware, electronics, screws, etc.)
+    that need to be acquired for a project.
+    """
 
     __tablename__ = "project_bom_items"
 
@@ -15,16 +19,20 @@ class ProjectBOMItem(Base):
     project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     name: Mapped[str] = mapped_column(String(255))
     quantity_needed: Mapped[int] = mapped_column(Integer, default=1)
-    quantity_printed: Mapped[int] = mapped_column(Integer, default=0)
+    quantity_acquired: Mapped[int] = mapped_column(Integer, default=0)
+
+    # Sourcing information
+    unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+    sourcing_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
 
-    # Optional link to archive that prints this part
+    # Optional link to archive (for reference)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
 
-    # Reference to attachment filename (STL file)
+    # Reference to attachment filename
     stl_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
 
-    # Notes about this part
-    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+    # Remarks about this part
+    remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
 
     # Sort order
     sort_order: Mapped[int] = mapped_column(Integer, default=0)

+ 14 - 6
backend/app/schemas/project.py

@@ -100,6 +100,8 @@ class ArchivePreview(BaseModel):
     print_name: str | None
     thumbnail_path: str | None
     status: str
+    filament_type: str | None = None
+    filament_color: str | None = None
 
 
 class ProjectListResponse(BaseModel):
@@ -135,15 +137,17 @@ class BatchAddQueueItems(BaseModel):
     queue_item_ids: list[int]
 
 
-# Phase 7: BOM Schemas
+# Phase 7: BOM Schemas - Tracks sourced/purchased parts
 class BOMItemCreate(BaseModel):
     """Schema for creating a BOM item."""
 
     name: str
     quantity_needed: int = 1
+    unit_price: float | None = None
+    sourcing_url: str | None = None
     archive_id: int | None = None
     stl_filename: str | None = None
-    notes: str | None = None
+    remarks: str | None = None
 
 
 class BOMItemUpdate(BaseModel):
@@ -151,10 +155,12 @@ class BOMItemUpdate(BaseModel):
 
     name: str | None = None
     quantity_needed: int | None = None
-    quantity_printed: int | None = None
+    quantity_acquired: int | None = None
+    unit_price: float | None = None
+    sourcing_url: str | None = None
     archive_id: int | None = None
     stl_filename: str | None = None
-    notes: str | None = None
+    remarks: str | None = None
 
 
 class BOMItemResponse(BaseModel):
@@ -164,11 +170,13 @@ class BOMItemResponse(BaseModel):
     project_id: int
     name: str
     quantity_needed: int
-    quantity_printed: int
+    quantity_acquired: int
+    unit_price: float | None
+    sourcing_url: str | None
     archive_id: int | None
     archive_name: str | None = None
     stl_filename: str | None
-    notes: str | None
+    remarks: str | None
     sort_order: int
     is_complete: bool = False
     created_at: datetime

+ 16 - 6
frontend/src/api/client.ts

@@ -384,6 +384,8 @@ export interface ArchivePreview {
   print_name: string | null;
   thumbnail_path: string | null;
   status: string;
+  filament_type: string | null;
+  filament_color: string | null;
 }
 
 export interface ProjectListItem {
@@ -427,17 +429,19 @@ export interface ProjectUpdate {
   parent_id?: number;
 }
 
-// BOM Types
+// BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.)
 export interface BOMItem {
   id: number;
   project_id: number;
   name: string;
   quantity_needed: number;
-  quantity_printed: number;
+  quantity_acquired: number;
+  unit_price: number | null;
+  sourcing_url: string | null;
   archive_id: number | null;
   archive_name: string | null;
   stl_filename: string | null;
-  notes: string | null;
+  remarks: string | null;
   sort_order: number;
   is_complete: boolean;
   created_at: string;
@@ -447,18 +451,22 @@ export interface BOMItem {
 export interface BOMItemCreate {
   name: string;
   quantity_needed?: number;
+  unit_price?: number;
+  sourcing_url?: string;
   archive_id?: number;
   stl_filename?: string;
-  notes?: string;
+  remarks?: string;
 }
 
 export interface BOMItemUpdate {
   name?: string;
   quantity_needed?: number;
-  quantity_printed?: number;
+  quantity_acquired?: number;
+  unit_price?: number;
+  sourcing_url?: string;
   archive_id?: number;
   stl_filename?: string;
-  notes?: string;
+  remarks?: string;
 }
 
 // Timeline Types
@@ -1572,10 +1580,12 @@ export const api = {
       if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications));
       if (categories.templates !== undefined) params.set('include_templates', String(categories.templates));
       if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));
+      if (categories.external_links !== undefined) params.set('include_external_links', String(categories.external_links));
       if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));
       if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
       if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
       if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
+      if (categories.projects !== undefined) params.set('include_projects', String(categories.projects));
       if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
     }
     const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;

+ 17 - 1
frontend/src/components/BackupModal.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react';
-import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle } from 'lucide-react';
+import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -47,6 +47,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: true,
     description: 'Tasmota plug configurations',
   },
+  {
+    id: 'external_links',
+    labelKey: 'backup.categories.externalLinks',
+    defaultLabel: 'External Links',
+    icon: <Link className="w-4 h-4" />,
+    default: true,
+    description: 'Sidebar links to external services',
+  },
   {
     id: 'printers',
     labelKey: 'backup.categories.printers',
@@ -79,6 +87,14 @@ const BACKUP_CATEGORIES: BackupCategory[] = [
     default: false,
     description: 'All print data + files (3MF, thumbnails, photos)',
   },
+  {
+    id: 'projects',
+    labelKey: 'backup.categories.projects',
+    defaultLabel: 'Projects',
+    icon: <FolderKanban className="w-4 h-4" />,
+    default: false,
+    description: 'Projects, BOM items, and attachments',
+  },
 ];
 
 interface BackupModalProps {

+ 242 - 89
frontend/src/pages/ProjectDetailPage.tsx

@@ -23,13 +23,13 @@ import {
   Download,
   Trash2,
   File,
-  DollarSign,
-  ClipboardList,
   Plus,
   History,
   FolderTree,
   Copy,
   Layers,
+  ExternalLink,
+  ShoppingCart,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
@@ -37,6 +37,7 @@ import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { RichTextEditor } from '../components/RichTextEditor';
+import { ConfirmModal } from '../components/ConfirmModal';
 
 // Project edit modal (reused from ProjectsPage)
 import { ProjectModal } from './ProjectsPage';
@@ -227,6 +228,13 @@ export function ProjectDetailPage() {
     enabled: projectId > 0,
   });
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const currency = settings?.currency || '$';
+
   const updateMutation = useMutation({
     mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
     onSuccess: () => {
@@ -261,9 +269,9 @@ export function ProjectDetailPage() {
 
     setUploadingAttachment(true);
     try {
-      await api.uploadProjectAttachment(projectId, file);
+      const result = await api.uploadProjectAttachment(projectId, file);
       queryClient.invalidateQueries({ queryKey: ['project', projectId] });
-      showToast('Attachment uploaded', 'success');
+      showToast(`Uploaded: ${result.original_name}`, 'success');
     } catch (error) {
       showToast((error as Error).message, 'error');
     } finally {
@@ -274,16 +282,22 @@ export function ProjectDetailPage() {
     }
   };
 
-  const handleDeleteAttachment = async (filename: string) => {
-    if (!confirm('Delete this attachment?')) return;
-
-    try {
-      await api.deleteProjectAttachment(projectId, filename);
-      queryClient.invalidateQueries({ queryKey: ['project', projectId] });
-      showToast('Attachment deleted', 'success');
-    } catch (error) {
-      showToast((error as Error).message, 'error');
-    }
+  const handleDeleteAttachment = (filename: string, originalName: string) => {
+    setConfirmModal({
+      isOpen: true,
+      title: 'Delete Attachment',
+      message: `Are you sure you want to delete "${originalName}"?`,
+      onConfirm: async () => {
+        setConfirmModal(prev => ({ ...prev, isOpen: false }));
+        try {
+          await api.deleteProjectAttachment(projectId, filename);
+          queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+          showToast('Attachment deleted', 'success');
+        } catch (error) {
+          showToast((error as Error).message, 'error');
+        }
+      },
+    });
   };
 
   const formatFileSize = (bytes: number): string => {
@@ -295,6 +309,19 @@ export function ProjectDetailPage() {
   // BOM handlers
   const [newBomName, setNewBomName] = useState('');
   const [newBomQty, setNewBomQty] = useState(1);
+  const [newBomPrice, setNewBomPrice] = useState('');
+  const [newBomUrl, setNewBomUrl] = useState('');
+  const [newBomRemarks, setNewBomRemarks] = useState('');
+  const [showBomForm, setShowBomForm] = useState(false);
+  const [hideBomCompleted, setHideBomCompleted] = useState(false);
+
+  // Confirm modal state
+  const [confirmModal, setConfirmModal] = useState<{
+    isOpen: boolean;
+    title: string;
+    message: string;
+    onConfirm: () => void;
+  }>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
 
   const createBomMutation = useMutation({
     mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data),
@@ -303,13 +330,17 @@ export function ProjectDetailPage() {
       queryClient.invalidateQueries({ queryKey: ['project', projectId] });
       setNewBomName('');
       setNewBomQty(1);
-      showToast('BOM item added', 'success');
+      setNewBomPrice('');
+      setNewBomUrl('');
+      setNewBomRemarks('');
+      setShowBomForm(false);
+      showToast('Part added', 'success');
     },
     onError: (error: Error) => showToast(error.message, 'error'),
   });
 
   const updateBomMutation = useMutation({
-    mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_printed: number } }) =>
+    mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_acquired?: number } }) =>
       api.updateBOMItem(projectId, itemId, data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
@@ -323,7 +354,7 @@ export function ProjectDetailPage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
       queryClient.invalidateQueries({ queryKey: ['project', projectId] });
-      showToast('BOM item deleted', 'success');
+      showToast('Part removed', 'success');
     },
     onError: (error: Error) => showToast(error.message, 'error'),
   });
@@ -331,20 +362,33 @@ export function ProjectDetailPage() {
   const handleAddBomItem = (e: React.FormEvent) => {
     e.preventDefault();
     if (!newBomName.trim()) return;
-    createBomMutation.mutate({ name: newBomName.trim(), quantity_needed: newBomQty });
+    createBomMutation.mutate({
+      name: newBomName.trim(),
+      quantity_needed: newBomQty,
+      unit_price: newBomPrice ? parseFloat(newBomPrice) : undefined,
+      sourcing_url: newBomUrl.trim() || undefined,
+      remarks: newBomRemarks.trim() || undefined,
+    });
   };
 
-  const handleIncrementPrinted = (item: BOMItem) => {
+  const handleToggleAcquired = (item: BOMItem) => {
+    const newQty = item.is_complete ? 0 : item.quantity_needed;
     updateBomMutation.mutate({
       itemId: item.id,
-      data: { quantity_printed: item.quantity_printed + 1 },
+      data: { quantity_acquired: newQty },
     });
   };
 
-  const handleDeleteBomItem = (itemId: number) => {
-    if (confirm('Delete this BOM item?')) {
-      deleteBomMutation.mutate(itemId);
-    }
+  const handleDeleteBomItem = (itemId: number, itemName: string) => {
+    setConfirmModal({
+      isOpen: true,
+      title: 'Delete Part',
+      message: `Are you sure you want to delete "${itemName}"?`,
+      onConfirm: () => {
+        setConfirmModal(prev => ({ ...prev, isOpen: false }));
+        deleteBomMutation.mutate(itemId);
+      },
+    });
   };
 
   // Template handlers
@@ -395,7 +439,7 @@ export function ProjectDetailPage() {
     : null;
 
   return (
-    <div className="space-y-6">
+    <div className="p-4 md:p-8 space-y-8">
       {/* Breadcrumb */}
       <div className="flex items-center gap-2 text-sm text-bambu-gray">
         <Link to="/projects" className="hover:text-white transition-colors">
@@ -503,15 +547,14 @@ export function ProjectDetailPage() {
       {stats && (stats.estimated_cost > 0 || project.budget) && (
         <Card>
           <CardContent className="p-4">
-            <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
-              <DollarSign className="w-5 h-5" />
+            <h2 className="text-lg font-semibold text-white mb-3">
               Cost Tracking
             </h2>
             <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
               <div>
                 <p className="text-xs text-bambu-gray uppercase">Filament Cost</p>
                 <p className="text-lg font-semibold text-white">
-                  ${stats.estimated_cost.toFixed(2)}
+                  {currency}{stats.estimated_cost.toFixed(2)}
                 </p>
               </div>
               {stats.total_energy_kwh > 0 && (
@@ -521,7 +564,7 @@ export function ProjectDetailPage() {
                     {stats.total_energy_kwh.toFixed(2)} kWh
                     {stats.total_energy_cost > 0 && (
                       <span className="text-sm text-bambu-gray ml-1">
-                        (${stats.total_energy_cost.toFixed(2)})
+                        ({currency}{stats.total_energy_cost.toFixed(2)})
                       </span>
                     )}
                   </p>
@@ -531,12 +574,12 @@ export function ProjectDetailPage() {
                 <>
                   <div>
                     <p className="text-xs text-bambu-gray uppercase">Budget</p>
-                    <p className="text-lg font-semibold text-white">${project.budget.toFixed(2)}</p>
+                    <p className="text-lg font-semibold text-white">{currency}{project.budget.toFixed(2)}</p>
                   </div>
                   <div>
                     <p className="text-xs text-bambu-gray uppercase">Remaining</p>
                     <p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
-                      ${(project.budget - stats.estimated_cost).toFixed(2)}
+                      {currency}{(project.budget - stats.estimated_cost).toFixed(2)}
                     </p>
                   </div>
                 </>
@@ -734,6 +777,10 @@ export function ProjectDetailPage() {
             </div>
           </div>
 
+          <p className="text-xs text-bambu-gray mb-3">
+            Upload any file: images (PNG, JPG), PDFs, STL files, or documents.
+          </p>
+
           {project.attachments && project.attachments.length > 0 ? (
             <div className="space-y-2">
               {project.attachments.map((attachment) => (
@@ -762,7 +809,7 @@ export function ProjectDetailPage() {
                       <Download className="w-4 h-4" />
                     </a>
                     <button
-                      onClick={() => handleDeleteAttachment(attachment.filename)}
+                      onClick={() => handleDeleteAttachment(attachment.filename, attachment.original_name)}
                       className="p-2 rounded hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-red-400"
                       title="Delete"
                     >
@@ -774,51 +821,106 @@ export function ProjectDetailPage() {
             </div>
           ) : (
             <p className="text-bambu-gray/70 text-sm italic">
-              No attachments. Upload STL files, reference images, or other documents.
+              No attachments yet. Click Upload to add files.
             </p>
           )}
         </CardContent>
       </Card>
 
-      {/* BOM Section */}
+      {/* BOM Section - Parts to source/purchase */}
       <Card>
         <CardContent className="p-4">
-          <div className="flex items-center justify-between mb-3">
+          <div className="flex items-center justify-between mb-4">
             <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-              <ClipboardList className="w-5 h-5" />
+              <ShoppingCart className="w-5 h-5" />
               Bill of Materials
               {stats && stats.bom_total_items > 0 && (
                 <span className="text-sm font-normal text-bambu-gray">
-                  ({stats.bom_completed_items}/{stats.bom_total_items} complete)
+                  ({stats.bom_completed_items}/{stats.bom_total_items} acquired)
                 </span>
               )}
             </h2>
+            <div className="flex items-center gap-2">
+              {bomItems && bomItems.some(item => item.is_complete) && (
+                <button
+                  onClick={() => setHideBomCompleted(!hideBomCompleted)}
+                  className={`text-xs px-2 py-1 rounded transition-colors ${
+                    hideBomCompleted
+                      ? 'bg-bambu-green/20 text-bambu-green'
+                      : 'bg-bambu-dark text-bambu-gray hover:text-white'
+                  }`}
+                >
+                  {hideBomCompleted ? 'Show all' : 'Hide done'}
+                </button>
+              )}
+              {!showBomForm && (
+                <Button variant="secondary" size="sm" onClick={() => setShowBomForm(true)}>
+                  <Plus className="w-4 h-4 mr-1" />
+                  Add Part
+                </Button>
+              )}
+            </div>
           </div>
 
           {/* Add BOM item form */}
-          <form onSubmit={handleAddBomItem} className="flex gap-2 mb-4">
-            <input
-              type="text"
-              value={newBomName}
-              onChange={(e) => setNewBomName(e.target.value)}
-              className="flex-1 bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-              placeholder="Part name..."
-            />
-            <input
-              type="number"
-              value={newBomQty}
-              onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
-              className="w-20 bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
-              min="1"
-            />
-            <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
-              {createBomMutation.isPending ? (
-                <Loader2 className="w-4 h-4 animate-spin" />
-              ) : (
-                <Plus className="w-4 h-4" />
-              )}
-            </Button>
-          </form>
+          {showBomForm && (
+            <form onSubmit={handleAddBomItem} className="bg-bambu-dark rounded-lg p-4 mb-4 space-y-3">
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+                <input
+                  type="text"
+                  value={newBomName}
+                  onChange={(e) => setNewBomName(e.target.value)}
+                  className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                  placeholder="Part name (e.g., M3x8 screws)"
+                  autoFocus
+                />
+                <div className="flex gap-2">
+                  <input
+                    type="number"
+                    value={newBomQty}
+                    onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
+                    className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
+                    min="1"
+                    placeholder="Qty"
+                  />
+                  <input
+                    type="number"
+                    step="0.01"
+                    value={newBomPrice}
+                    onChange={(e) => setNewBomPrice(e.target.value)}
+                    className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                    placeholder={`Price (${currency})`}
+                  />
+                </div>
+              </div>
+              <input
+                type="url"
+                value={newBomUrl}
+                onChange={(e) => setNewBomUrl(e.target.value)}
+                className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                placeholder="Sourcing URL (optional)"
+              />
+              <input
+                type="text"
+                value={newBomRemarks}
+                onChange={(e) => setNewBomRemarks(e.target.value)}
+                className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                placeholder="Remarks (optional)"
+              />
+              <div className="flex justify-end gap-2">
+                <Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
+                  Cancel
+                </Button>
+                <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
+                  {createBomMutation.isPending ? (
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                  ) : (
+                    'Add Part'
+                  )}
+                </Button>
+              </div>
+            </form>
+          )}
 
           {bomLoading ? (
             <div className="flex items-center justify-center py-4">
@@ -826,55 +928,94 @@ export function ProjectDetailPage() {
             </div>
           ) : bomItems && bomItems.length > 0 ? (
             <div className="space-y-2">
-              {bomItems.map((item) => (
+              {bomItems
+                .filter(item => !hideBomCompleted || !item.is_complete)
+                .map((item) => (
                 <div
                   key={item.id}
-                  className={`flex items-center justify-between p-3 rounded-lg ${
+                  className={`p-3 rounded-lg transition-colors ${
                     item.is_complete ? 'bg-bambu-green/10' : 'bg-bambu-dark'
                   }`}
                 >
-                  <div className="flex items-center gap-3 min-w-0">
+                  <div className="flex items-start gap-3">
                     <button
-                      onClick={() => handleIncrementPrinted(item)}
+                      onClick={() => handleToggleAcquired(item)}
                       disabled={updateBomMutation.isPending}
-                      className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-colors ${
+                      className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
                         item.is_complete
                           ? 'bg-bambu-green border-bambu-green text-white'
                           : 'border-bambu-gray hover:border-bambu-green'
                       }`}
                     >
-                      {item.is_complete && <CheckCircle className="w-4 h-4" />}
+                      {item.is_complete && <CheckCircle className="w-3 h-3" />}
                     </button>
-                    <div className="min-w-0">
-                      <p className={`text-sm ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
-                        {item.name}
-                      </p>
-                      <p className="text-xs text-bambu-gray">
-                        {item.quantity_printed} / {item.quantity_needed} printed
-                      </p>
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center justify-between gap-2">
+                        <div className="flex items-center gap-2 min-w-0">
+                          <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
+                            {item.name}
+                            <span className="text-bambu-gray font-normal ml-2">
+                              x{item.quantity_needed}
+                            </span>
+                          </p>
+                          {item.unit_price !== null && (
+                            <span className="text-xs text-bambu-green whitespace-nowrap">
+                              {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
+                            </span>
+                          )}
+                        </div>
+                        <button
+                          onClick={() => handleDeleteBomItem(item.id, item.name)}
+                          className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
+                          title="Delete"
+                        >
+                          <Trash2 className="w-4 h-4" />
+                        </button>
+                      </div>
+                      {/* Sourcing URL */}
+                      {item.sourcing_url && (
+                        <a
+                          href={item.sourcing_url}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
+                          onClick={(e) => e.stopPropagation()}
+                        >
+                          <ExternalLink className="w-3 h-3 flex-shrink-0" />
+                          <span className="truncate">
+                            {(() => {
+                              try {
+                                return new URL(item.sourcing_url).hostname.replace('www.', '');
+                              } catch {
+                                return item.sourcing_url;
+                              }
+                            })()}
+                          </span>
+                        </a>
+                      )}
+                      {/* Remarks */}
+                      {item.remarks && (
+                        <p className="mt-1 text-xs text-bambu-gray/80 italic">
+                          {item.remarks}
+                        </p>
+                      )}
                     </div>
                   </div>
-                  <div className="flex items-center gap-2">
-                    <button
-                      onClick={() => handleIncrementPrinted(item)}
-                      disabled={updateBomMutation.isPending}
-                      className="px-2 py-1 text-xs bg-bambu-dark-tertiary rounded hover:bg-bambu-green/20 text-bambu-gray hover:text-white transition-colors"
-                    >
-                      +1
-                    </button>
-                    <button
-                      onClick={() => handleDeleteBomItem(item.id)}
-                      className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors"
-                    >
-                      <Trash2 className="w-4 h-4" />
-                    </button>
-                  </div>
                 </div>
               ))}
+              {/* BOM Total */}
+              {bomItems.some(item => item.unit_price !== null) && (
+                <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
+                  <span className="text-bambu-gray">Total cost:</span>
+                  <span className="text-white font-medium">
+                    {currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
+                  </span>
+                </div>
+              )}
             </div>
           ) : (
             <p className="text-bambu-gray/70 text-sm italic">
-              No parts in the bill of materials. Add parts to track what needs to be printed.
+              No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.
             </p>
           )}
         </CardContent>
@@ -1011,6 +1152,18 @@ export function ProjectDetailPage() {
           isLoading={updateMutation.isPending}
         />
       )}
+
+      {/* Confirm Modal */}
+      {confirmModal.isOpen && (
+        <ConfirmModal
+          title={confirmModal.title}
+          message={confirmModal.message}
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={confirmModal.onConfirm}
+          onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
+        />
+      )}
     </div>
   );
 }

+ 110 - 40
frontend/src/pages/ProjectsPage.tsx

@@ -240,13 +240,16 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
 
   return (
     <div
-      className="group relative bg-bambu-card rounded-xl border border-bambu-dark-tertiary hover:border-bambu-gray/40 transition-all cursor-pointer overflow-hidden"
+      className="group relative bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary hover:border-bambu-green/50 hover:shadow-lg hover:shadow-bambu-green/5 transition-all duration-300 cursor-pointer overflow-hidden"
       onClick={onClick}
     >
-      {/* Color accent bar */}
+      {/* Color accent bar with glow */}
       <div
-        className="absolute top-0 left-0 w-1 h-full"
-        style={{ backgroundColor: project.color || '#6b7280' }}
+        className="absolute top-0 left-0 w-1.5 h-full"
+        style={{
+          backgroundColor: project.color || '#6b7280',
+          boxShadow: `0 0 12px ${project.color || '#6b7280'}40`
+        }}
       />
 
       <div className="p-5 pl-6">
@@ -257,8 +260,21 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
               <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
             </div>
             <div className="min-w-0 flex-1">
-              <div className="flex items-center gap-2">
+              <div className="flex items-center gap-2 flex-wrap">
                 <h3 className="font-semibold text-white truncate">{project.name}</h3>
+                {project.target_count ? (
+                  <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
+                    progressPercent >= 100
+                      ? 'bg-bambu-green/20 text-bambu-green'
+                      : 'bg-bambu-dark text-bambu-gray'
+                  }`}>
+                    {project.archive_count}/{project.target_count} parts
+                  </span>
+                ) : project.archive_count > 0 ? (
+                  <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
+                    {project.archive_count} print{project.archive_count !== 1 ? 's' : ''}
+                  </span>
+                ) : null}
                 {isCompleted && (
                   <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
                     Done
@@ -275,6 +291,38 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                   {project.description}
                 </p>
               )}
+              {/* Filament materials/colors */}
+              {project.archives && project.archives.length > 0 && (() => {
+                const materials = [...new Set(project.archives.map(a => a.filament_type).filter(Boolean))];
+                const colors = [...new Set(project.archives.map(a => a.filament_color).filter(Boolean))] as string[];
+                if (materials.length === 0 && colors.length === 0) return null;
+                return (
+                  <div className="flex items-center gap-2 mt-1.5">
+                    {/* Material types as text badges */}
+                    {materials.slice(0, 3).map((mat) => (
+                      <span key={mat} className="text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded">
+                        {mat}
+                      </span>
+                    ))}
+                    {/* Colors as swatches */}
+                    {colors.length > 0 && (
+                      <div className="flex items-center gap-0.5">
+                        {colors.slice(0, 5).map((col) => (
+                          <div
+                            key={col}
+                            className="w-3 h-3 rounded-full border border-white/20"
+                            style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}
+                            title={col}
+                          />
+                        ))}
+                        {colors.length > 5 && (
+                          <span className="text-[10px] text-bambu-gray ml-0.5">+{colors.length - 5}</span>
+                        )}
+                      </div>
+                    )}
+                  </div>
+                );
+              })()}
             </div>
           </div>
 
@@ -310,38 +358,60 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
           </div>
         </div>
 
-        {/* Progress section */}
-        {project.target_count ? (
-          <div className="mb-4">
-            <div className="flex items-center justify-between text-xs mb-2">
-              <span className="text-bambu-gray">Progress</span>
-              <span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
-                {project.archive_count} / {project.target_count}
-              </span>
-            </div>
-            <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
-              <div
-                className="h-full transition-all duration-500 ease-out rounded-full"
-                style={{
-                  width: `${Math.min(progressPercent, 100)}%`,
-                  backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
-                }}
-              />
+        {/* Progress section - show for all projects */}
+        <div className="mb-4">
+          {project.target_count ? (
+            <>
+              <div className="flex items-center justify-between text-xs mb-2">
+                <span className="text-bambu-gray">Progress</span>
+                <span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
+                  {project.archive_count} / {project.target_count}
+                </span>
+              </div>
+              <div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
+                <div
+                  className="h-full transition-all duration-500 ease-out rounded-full relative"
+                  style={{
+                    width: `${Math.min(progressPercent, 100)}%`,
+                    background: progressPercent >= 100
+                      ? 'linear-gradient(90deg, #22c55e, #4ade80)'
+                      : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
+                    boxShadow: `0 0 8px ${progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
+                  }}
+                />
+              </div>
+              <div className="text-right text-xs text-bambu-gray/60 mt-1">
+                {progressPercent.toFixed(0)}% complete
+              </div>
+            </>
+          ) : project.archive_count > 0 ? (
+            <div className="flex items-center gap-4 text-xs">
+              <div className="flex items-center gap-1.5 text-bambu-gray">
+                <Archive className="w-3.5 h-3.5" />
+                <span>{project.archive_count} print{project.archive_count !== 1 ? 's' : ''} completed</span>
+              </div>
+              {project.queue_count > 0 && (
+                <div className="flex items-center gap-1.5 text-blue-400">
+                  <Clock className="w-3.5 h-3.5" />
+                  <span>{project.queue_count} in queue</span>
+                </div>
+              )}
             </div>
-            <div className="text-right text-xs text-bambu-gray/60 mt-1">
-              {progressPercent.toFixed(0)}% complete
+          ) : (
+            <div className="text-xs text-bambu-gray/60 italic">
+              No prints yet
             </div>
-          </div>
-        ) : null}
+          )}
+        </div>
 
-        {/* Archive thumbnails grid */}
+        {/* Archive thumbnails - compact 4-column grid */}
         {project.archives && project.archives.length > 0 && (
           <div className="mb-4">
-            <div className="flex gap-1.5">
+            <div className="grid grid-cols-4 gap-1.5">
               {project.archives.slice(0, 4).map((archive) => (
                 <div
                   key={archive.id}
-                  className="relative w-12 h-12 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary"
+                  className="relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary"
                   title={archive.print_name || 'Unknown'}
                 >
                   {archive.thumbnail_path ? (
@@ -352,22 +422,22 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                     />
                   ) : (
                     <div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
-                      <Package className="w-5 h-5" />
+                      <Package className="w-6 h-6" />
                     </div>
                   )}
                   {archive.status === 'failed' && (
-                    <div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
+                    <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
                       <AlertTriangle className="w-4 h-4 text-white" />
                     </div>
                   )}
                 </div>
               ))}
-              {project.archive_count > 4 && (
-                <div className="w-12 h-12 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-xs text-bambu-gray border border-bambu-dark-tertiary">
-                  +{project.archive_count - 4}
-                </div>
-              )}
             </div>
+            {project.archive_count > 4 && (
+              <p className="text-xs text-bambu-gray mt-1.5 text-center">
+                +{project.archive_count - 4} more
+              </p>
+            )}
           </div>
         )}
 
@@ -480,17 +550,17 @@ export function ProjectsPage() {
   }, {} as Record<string, number>) || {};
 
   return (
-    <div className="space-y-6">
+    <div className="p-4 md:p-8 space-y-8">
       {/* Header */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
         <div>
           <h1 className="text-2xl font-bold text-white flex items-center gap-3">
-            <div className="p-2 bg-bambu-green/10 rounded-lg">
+            <div className="p-2.5 bg-bambu-green/10 rounded-xl">
               <FolderKanban className="w-6 h-6 text-bambu-green" />
             </div>
             Projects
           </h1>
-          <p className="text-sm text-bambu-gray mt-1 ml-14">
+          <p className="text-sm text-bambu-gray mt-2 ml-14">
             Organize and track your 3D printing projects
           </p>
         </div>
@@ -560,7 +630,7 @@ export function ProjectsPage() {
           )}
         </div>
       ) : (
-        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
+        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
           {projects?.map((project) => (
             <ProjectCard
               key={project.id}

Plik diff jest za duży
+ 0 - 0
static/assets/index-BonMKEhM.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-CUXYdb1N.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-DGJjegsB.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-DpxuzRSm.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-DpxuzRSm.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CUXYdb1N.css">
+    <script type="module" crossorigin src="/assets/index-BonMKEhM.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DGJjegsB.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików