Przeglądaj źródła

Merge pull request #17 from maziggy/0.1.5b

0.1.5b4

- Removed debug logs for backup/restore module
- Fixed a bug in notification module where newly added notification types were not respected
- External links can now be embedded into sidebar navigation
- Added embedded external links to backup/restore module
- Add comprehensive mobile support with responsive navigation
- Fixed layout for external link icons
- Improved external link module
- Add comprehensive documentation
- Moved documentation to it's own repository
- Added issue/pull request templates
- Added Docker support
- Added filament spool fill levels to printer card
- Changed version to 0.1.5b4
MartinNYHC 5 miesięcy temu
rodzic
commit
e1aa557862
41 zmienionych plików z 1781 dodań i 246 usunięć
  1. BIN
      ._.DS_Store
  2. 53 0
      .dockerignore
  3. 37 0
      Dockerfile
  4. 268 0
      backend/app/api/routes/external_links.py
  5. 5 0
      backend/app/api/routes/notifications.py
  6. 86 50
      backend/app/api/routes/settings.py
  7. 1 1
      backend/app/core/config.py
  8. 1 1
      backend/app/core/database.py
  9. 2 1
      backend/app/main.py
  10. 25 0
      backend/app/models/external_link.py
  11. 58 0
      backend/app/schemas/external_link.py
  12. 3 0
      build_docker.sh
  13. 16 0
      docker-compose.yml
  14. 2 0
      frontend/src/App.tsx
  15. 61 0
      frontend/src/api/client.ts
  16. 299 0
      frontend/src/components/AddExternalLinkModal.tsx
  17. 3 3
      frontend/src/components/Button.tsx
  18. 1 1
      frontend/src/components/Dashboard.tsx
  19. 187 0
      frontend/src/components/ExternalLinksSettings.tsx
  20. 132 0
      frontend/src/components/IconPicker.tsx
  21. 289 125
      frontend/src/components/Layout.tsx
  22. 2 2
      frontend/src/components/Toggle.tsx
  23. 26 0
      frontend/src/hooks/useIsMobile.ts
  24. 46 0
      frontend/src/hooks/useLongPress.ts
  25. 38 0
      frontend/src/index.css
  26. 37 16
      frontend/src/pages/ArchivesPage.tsx
  27. 43 0
      frontend/src/pages/ExternalLinkPage.tsx
  28. 2 2
      frontend/src/pages/MaintenancePage.tsx
  29. 41 30
      frontend/src/pages/PrintersPage.tsx
  30. 1 1
      frontend/src/pages/ProfilesPage.tsx
  31. 3 3
      frontend/src/pages/QueuePage.tsx
  32. 9 6
      frontend/src/pages/SettingsPage.tsx
  33. 2 2
      frontend/src/pages/StatsPage.tsx
  34. 0 0
      icons/27ca5e207eb045a7949048ab41fda285.svg
  35. 0 0
      icons/57eeee2303f848be9d6159c1079f100d.svg
  36. 0 0
      icons/df3231e72d3b4bc0a08c47e95599e64d.svg
  37. 0 0
      static/assets/index-2RQZZHMw.js
  38. 0 0
      static/assets/index-BYZOEJWU.css
  39. 0 0
      static/assets/index-CycmYzoY.js
  40. 0 0
      static/assets/index-Ob3MFXab.css
  41. 2 2
      static/index.html

BIN
._.DS_Store


+ 53 - 0
.dockerignore

@@ -0,0 +1,53 @@
+# Git
+.git
+.gitignore
+
+# Python
+__pycache__
+*.py[cod]
+*$py.class
+*.so
+.Python
+venv/
+.venv/
+ENV/
+env/
+.env
+*.egg-info/
+.eggs/
+dist/
+build/
+
+# Node
+frontend/node_modules/
+frontend/.npm
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
+
+# Logs and data (will be mounted as volumes)
+logs/
+data/
+*.log
+*.db
+
+# Build artifacts
+static/
+
+# Documentation
+docs/
+*.md
+!requirements.txt
+
+# Docker
+Dockerfile
+docker-compose*.yml
+.dockerignore

+ 37 - 0
Dockerfile

@@ -0,0 +1,37 @@
+# Build frontend
+FROM node:22-bookworm-slim AS frontend-builder
+
+WORKDIR /app/frontend
+
+COPY frontend/package*.json ./
+RUN npm ci
+
+COPY frontend/ ./
+RUN npm run build
+
+# Production image
+FROM python:3.13-slim
+
+WORKDIR /app
+
+# Install dependencies
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy backend
+COPY backend/ ./backend/
+
+# Copy built frontend from builder stage
+COPY --from=frontend-builder /app/static ./static
+
+# Create data directory for persistent storage
+RUN mkdir -p /app/data /app/logs
+
+# Environment variables
+ENV PYTHONUNBUFFERED=1
+ENV DATA_DIR=/app/data
+
+EXPOSE 8000
+
+# Run the application
+CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]

+ 268 - 0
backend/app/api/routes/external_links.py

@@ -0,0 +1,268 @@
+"""API routes for external sidebar links."""
+
+import logging
+import os
+import uuid
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from fastapi.responses import FileResponse
+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.external_link import ExternalLink
+from backend.app.schemas.external_link import (
+    ExternalLinkCreate,
+    ExternalLinkUpdate,
+    ExternalLinkResponse,
+    ExternalLinkReorder,
+)
+
+# Directory for storing custom icons
+ICONS_DIR = app_settings.base_dir / "icons"
+ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/external-links", tags=["external-links"])
+
+
+@router.get("/", response_model=list[ExternalLinkResponse])
+async def list_external_links(db: AsyncSession = Depends(get_db)):
+    """List all external links ordered by sort_order."""
+    result = await db.execute(
+        select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id)
+    )
+    links = result.scalars().all()
+    return links
+
+
+@router.post("/", response_model=ExternalLinkResponse)
+async def create_external_link(
+    link_data: ExternalLinkCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new external link."""
+    # Get the highest sort_order to place new link at end
+    result = await db.execute(
+        select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1)
+    )
+    last_link = result.scalar_one_or_none()
+    next_order = (last_link.sort_order + 1) if last_link else 0
+
+    link = ExternalLink(
+        name=link_data.name,
+        url=link_data.url,
+        icon=link_data.icon,
+        sort_order=next_order,
+    )
+
+    db.add(link)
+    await db.commit()
+    await db.refresh(link)
+
+    logger.info(f"Created external link: {link.name} -> {link.url}")
+
+    return link
+
+
+@router.get("/{link_id}", response_model=ExternalLinkResponse)
+async def get_external_link(
+    link_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a specific external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    return link
+
+
+@router.patch("/{link_id}", response_model=ExternalLinkResponse)
+async def update_external_link(
+    link_id: int,
+    update_data: ExternalLinkUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    # Update only provided fields
+    update_dict = update_data.model_dump(exclude_unset=True)
+    for key, value in update_dict.items():
+        setattr(link, key, value)
+
+    await db.commit()
+    await db.refresh(link)
+
+    logger.info(f"Updated external link: {link.name}")
+
+    return link
+
+
+@router.delete("/{link_id}")
+async def delete_external_link(
+    link_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    name = link.name
+    await db.delete(link)
+    await db.commit()
+
+    logger.info(f"Deleted external link: {name}")
+
+    return {"message": f"External link '{name}' deleted"}
+
+
+@router.put("/reorder", response_model=list[ExternalLinkResponse])
+async def reorder_external_links(
+    reorder_data: ExternalLinkReorder,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update the sort order of external links."""
+    # Update sort_order for each link based on position in the list
+    for index, link_id in enumerate(reorder_data.ids):
+        result = await db.execute(
+            select(ExternalLink).where(ExternalLink.id == link_id)
+        )
+        link = result.scalar_one_or_none()
+        if link:
+            link.sort_order = index
+
+    await db.commit()
+
+    # Return updated list
+    result = await db.execute(
+        select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id)
+    )
+    links = result.scalars().all()
+
+    logger.info(f"Reordered {len(reorder_data.ids)} external links")
+
+    return links
+
+
+@router.post("/{link_id}/icon", response_model=ExternalLinkResponse)
+async def upload_icon(
+    link_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload a custom icon for an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    # Validate file extension
+    if not file.filename:
+        raise HTTPException(status_code=400, detail="No filename provided")
+
+    ext = Path(file.filename).suffix.lower()
+    if ext not in ALLOWED_EXTENSIONS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
+        )
+
+    # Create icons directory if it doesn't exist
+    ICONS_DIR.mkdir(parents=True, exist_ok=True)
+
+    # Delete old custom icon if exists
+    if link.custom_icon:
+        old_path = ICONS_DIR / link.custom_icon
+        if old_path.exists():
+            old_path.unlink()
+
+    # Generate unique filename
+    filename = f"{uuid.uuid4().hex}{ext}"
+    filepath = ICONS_DIR / filename
+
+    # Save file
+    content = await file.read()
+    with open(filepath, "wb") as f:
+        f.write(content)
+
+    # Update link
+    link.custom_icon = filename
+    await db.commit()
+    await db.refresh(link)
+
+    logger.info(f"Uploaded custom icon for link {link.name}: {filename}")
+
+    return link
+
+
+@router.delete("/{link_id}/icon", response_model=ExternalLinkResponse)
+async def delete_icon(
+    link_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete the custom icon for an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    if link.custom_icon:
+        filepath = ICONS_DIR / link.custom_icon
+        if filepath.exists():
+            filepath.unlink()
+        link.custom_icon = None
+        await db.commit()
+        await db.refresh(link)
+        logger.info(f"Deleted custom icon for link {link.name}")
+
+    return link
+
+
+@router.get("/{link_id}/icon")
+async def get_icon(
+    link_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the custom icon for an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    if not link.custom_icon:
+        raise HTTPException(status_code=404, detail="No custom icon set")
+
+    filepath = ICONS_DIR / link.custom_icon
+    if not filepath.exists():
+        raise HTTPException(status_code=404, detail="Icon file not found")
+
+    return FileResponse(filepath)

+ 5 - 0
backend/app/api/routes/notifications.py

@@ -94,15 +94,20 @@ async def create_notification_provider(
         on_print_start=provider_data.on_print_start,
         on_print_complete=provider_data.on_print_complete,
         on_print_failed=provider_data.on_print_failed,
+        on_print_stopped=provider_data.on_print_stopped,
         on_print_progress=provider_data.on_print_progress,
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,
         on_filament_low=provider_data.on_filament_low,
+        on_maintenance_due=provider_data.on_maintenance_due,
         # Quiet hours
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_start=provider_data.quiet_hours_start,
         quiet_hours_end=provider_data.quiet_hours_end,
+        # Daily digest
+        daily_digest_enabled=provider_data.daily_digest_enabled,
+        daily_digest_time=provider_data.daily_digest_time,
         # Printer filter
         printer_id=provider_data.printer_id,
     )

+ 86 - 50
backend/app/api/routes/settings.py

@@ -20,6 +20,7 @@ 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.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
@@ -167,6 +168,7 @@ async def export_backup(
     include_notifications: bool = Query(True, description="Include notification providers"),
     include_templates: bool = Query(True, description="Include notification templates"),
     include_smart_plugs: bool = Query(True, description="Include smart plugs"),
+    include_external_links: bool = Query(True, description="Include external sidebar links"),
     include_printers: bool = Query(False, description="Include printers (without access codes)"),
     include_filaments: bool = Query(False, description="Include filament inventory"),
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
@@ -258,6 +260,28 @@ async def export_backup(
             })
         backup["included"].append("smart_plugs")
 
+    # External links
+    if include_external_links:
+        result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order))
+        links = result.scalars().all()
+        backup["external_links"] = []
+        icons_dir = app_settings.base_dir / "icons"
+        for link in links:
+            link_data = {
+                "name": link.name,
+                "url": link.url,
+                "icon": link.icon,
+                "sort_order": link.sort_order,
+            }
+            # Include custom icon file path if exists
+            if link.custom_icon:
+                link_data["custom_icon"] = link.custom_icon
+                icon_path = icons_dir / link.custom_icon
+                if icon_path.exists():
+                    link_data["custom_icon_path"] = f"icons/{link.custom_icon}"
+            backup["external_links"].append(link_data)
+        backup["included"].append("external_links")
+
     # Printers (access codes only included if explicitly requested)
     if include_printers:
         result = await db.execute(select(Printer))
@@ -322,8 +346,19 @@ async def export_backup(
             })
         backup["included"].append("maintenance_types")
 
+    # Collect files for ZIP (icons + archives)
+    backup_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
+
+    # Add external link icon files
+    if include_external_links and "external_links" in backup:
+        icons_dir = app_settings.base_dir / "icons"
+        for link_data in backup["external_links"]:
+            if "custom_icon_path" in link_data:
+                icon_path = icons_dir / link_data["custom_icon"]
+                if icon_path.exists():
+                    backup_files.append((link_data["custom_icon_path"], icon_path))
+
     # Print archives with file paths for ZIP
-    archive_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
     if include_archives:
         result = await db.execute(select(PrintArchive))
         archives = result.scalars().all()
@@ -366,25 +401,25 @@ async def export_backup(
                 file_path = base_dir / a.file_path
                 if file_path.exists():
                     archive_data["file_path"] = a.file_path
-                    archive_files.append((a.file_path, file_path))
+                    backup_files.append((a.file_path, file_path))
 
             if a.thumbnail_path:
                 thumb_path = base_dir / a.thumbnail_path
                 if thumb_path.exists():
                     archive_data["thumbnail_path"] = a.thumbnail_path
-                    archive_files.append((a.thumbnail_path, thumb_path))
+                    backup_files.append((a.thumbnail_path, thumb_path))
 
             if a.timelapse_path:
                 timelapse_path = base_dir / a.timelapse_path
                 if timelapse_path.exists():
                     archive_data["timelapse_path"] = a.timelapse_path
-                    archive_files.append((a.timelapse_path, timelapse_path))
+                    backup_files.append((a.timelapse_path, timelapse_path))
 
             if a.source_3mf_path:
                 source_path = base_dir / a.source_3mf_path
                 if source_path.exists():
                     archive_data["source_3mf_path"] = a.source_3mf_path
-                    archive_files.append((a.source_3mf_path, source_path))
+                    backup_files.append((a.source_3mf_path, source_path))
 
             # Include photos
             if a.photos:
@@ -392,21 +427,21 @@ async def export_backup(
                     photo_path = base_dir / "archive" / "photos" / photo
                     if photo_path.exists():
                         zip_photo_path = f"archive/photos/{photo}"
-                        archive_files.append((zip_photo_path, photo_path))
+                        backup_files.append((zip_photo_path, photo_path))
 
             backup["archives"].append(archive_data)
         backup["included"].append("archives")
 
-    # If archives included, create ZIP file with all files
-    if include_archives and archive_files:
+    # 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:
             # Add backup.json
             zf.writestr("backup.json", json.dumps(backup, indent=2))
 
-            # Add all archive files
+            # Add all backup files (icons, archives, etc.)
             added_files = set()
-            for zip_path, local_path in archive_files:
+            for zip_path, local_path in backup_files:
                 if zip_path not in added_files and local_path.exists():
                     try:
                         zf.write(local_path, zip_path)
@@ -481,6 +516,7 @@ async def import_backup(
         "notification_providers": 0,
         "notification_templates": 0,
         "smart_plugs": 0,
+        "external_links": 0,
         "printers": 0,
         "filaments": 0,
         "maintenance_types": 0,
@@ -490,6 +526,7 @@ async def import_backup(
         "notification_providers": 0,
         "notification_templates": 0,
         "smart_plugs": 0,
+        "external_links": 0,
         "printers": 0,
         "filaments": 0,
         "maintenance_types": 0,
@@ -498,24 +535,13 @@ async def import_backup(
     skipped_details = {
         "notification_providers": [],
         "smart_plugs": [],
+        "external_links": [],
         "printers": [],
         "filaments": [],
         "maintenance_types": [],
         "archives": [],
     }
 
-    # Log what's in the backup
-    import logging
-    restore_logger = logging.getLogger(__name__)
-    restore_logger.info(f"Restore: Backup version={backup.get('version')}, included={backup.get('included', [])}")
-    restore_logger.info(f"Restore: overwrite={overwrite}")
-    if "printers" in backup:
-        restore_logger.info(f"Restore: Backup contains {len(backup['printers'])} printers")
-        for p in backup["printers"]:
-            restore_logger.info(f"  - {p.get('name')}: access_code={'YES' if p.get('access_code') else 'NO'}, is_active={p.get('is_active')}")
-    else:
-        restore_logger.info("Restore: Backup does NOT contain printers")
-
     # Restore settings (always overwrites)
     if "settings" in backup:
         for key, value in backup["settings"].items():
@@ -664,20 +690,49 @@ async def import_backup(
                 db.add(plug)
                 restored["smart_plugs"] += 1
 
-    # Restore printers (skip or overwrite duplicates by serial_number)
-    import logging
-    logger = logging.getLogger(__name__)
+    # Restore external links (skip or overwrite duplicates by name+url)
+    if "external_links" in backup:
+        icons_dir = base_dir / "icons"
+        icons_dir.mkdir(parents=True, exist_ok=True)
+
+        for link_data in backup["external_links"]:
+            result = await db.execute(
+                select(ExternalLink).where(
+                    ExternalLink.name == link_data["name"],
+                    ExternalLink.url == link_data["url"]
+                )
+            )
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    existing.icon = link_data.get("icon", "link")
+                    existing.sort_order = link_data.get("sort_order", 0)
+                    # Handle custom icon
+                    if link_data.get("custom_icon"):
+                        existing.custom_icon = link_data["custom_icon"]
+                    restored["external_links"] += 1
+                else:
+                    skipped["external_links"] += 1
+                    skipped_details["external_links"].append(link_data["name"])
+            else:
+                link = ExternalLink(
+                    name=link_data["name"],
+                    url=link_data["url"],
+                    icon=link_data.get("icon", "link"),
+                    custom_icon=link_data.get("custom_icon"),
+                    sort_order=link_data.get("sort_order", 0),
+                )
+                db.add(link)
+                restored["external_links"] += 1
 
+    # Restore printers (skip or overwrite duplicates by serial_number)
     if "printers" in backup:
-        logger.info(f"Restore: Processing {len(backup['printers'])} printers from backup")
         for printer_data in backup["printers"]:
-            logger.info(f"Restore: Processing printer {printer_data.get('name')} (serial: {printer_data.get('serial_number')})")
             result = await db.execute(
                 select(Printer).where(Printer.serial_number == printer_data["serial_number"])
             )
             existing = result.scalar_one_or_none()
             if existing:
-                logger.info(f"Restore: Printer already exists (id={existing.id}, is_active={existing.is_active})")
                 if overwrite:
                     existing.name = printer_data["name"]
                     existing.ip_address = printer_data["ip_address"]
@@ -695,14 +750,11 @@ async def import_backup(
                         if isinstance(is_active_val, str):
                             is_active_val = is_active_val.lower() == "true"
                         existing.is_active = is_active_val
-                        logger.info(f"Restore: Updated access_code and is_active={is_active_val} from backup")
 
                     restored["printers"] += 1
-                    logger.info(f"Restore: Updated existing printer (overwrite=True)")
                 else:
                     skipped["printers"] += 1
                     skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
-                    logger.info(f"Restore: Skipped existing printer (overwrite=False)")
             else:
                 # Use access code from backup if provided, otherwise require manual setup
                 access_code = printer_data.get("access_code")
@@ -712,16 +764,6 @@ async def import_backup(
                 if isinstance(is_active_from_backup, str):
                     is_active_from_backup = is_active_from_backup.lower() == "true"
 
-                import logging
-                logger = logging.getLogger(__name__)
-                logger.info(f"Restore: Creating printer {printer_data['name']}")
-                logger.info(f"  - access_code in backup: {'YES' if 'access_code' in printer_data else 'NO'}")
-                logger.info(f"  - access_code value: {access_code[:4] + '...' if access_code and len(access_code) > 4 else access_code}")
-                logger.info(f"  - has_access_code (valid): {has_access_code}")
-                logger.info(f"  - is_active in backup: {printer_data.get('is_active')} (type: {type(printer_data.get('is_active')).__name__})")
-                logger.info(f"  - is_active_from_backup (converted): {is_active_from_backup}")
-                logger.info(f"  - final is_active: {is_active_from_backup if has_access_code else False}")
-
                 printer = Printer(
                     name=printer_data["name"],
                     serial_number=printer_data["serial_number"],
@@ -867,9 +909,6 @@ async def import_backup(
 
     await db.commit()
 
-    import logging
-    logger = logging.getLogger(__name__)
-
     # 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:
@@ -878,15 +917,12 @@ async def import_backup(
             select(Printer).where(Printer.is_active == True)
         )
         active_printers = result.scalars().all()
-        logger.info(f"Restore: Found {len(active_printers)} active printers to reconnect")
         for printer in active_printers:
-            logger.info(f"Restore: Reconnecting printer {printer.name} (id={printer.id}, ip={printer.ip_address}, access_code={'SET' if printer.access_code and printer.access_code != 'CHANGE_ME' else 'NOT SET'})")
             # This will disconnect existing connection (if any) and reconnect
             try:
-                connected = await printer_manager.connect_printer(printer)
-                logger.info(f"Restore: Printer {printer.name} connection result: {connected}")
-            except Exception as e:
-                logger.error(f"Restore: Failed to connect printer {printer.name}: {e}")
+                await printer_manager.connect_printer(printer)
+            except Exception:
+                pass  # Connection failed, but don't fail the restore
 
     # If settings were restored, check if Spoolman needs to be reconnected
     if "settings" in backup:

+ 1 - 1
backend/app/core/config.py

@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
 import logging
 
 # Application version - single source of truth
-APP_VERSION = "0.1.5b"
+APP_VERSION = "0.1.5b4"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # Base directory for path calculations

+ 1 - 1
backend/app/core/database.py

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 async def init_db():
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link  # noqa: F401
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)

+ 2 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -1031,6 +1031,7 @@ app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
+app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 25 - 0
backend/app/models/external_link.py

@@ -0,0 +1,25 @@
+from datetime import datetime
+from typing import Optional
+from sqlalchemy import String, Integer, DateTime, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class ExternalLink(Base):
+    """External links for sidebar navigation."""
+
+    __tablename__ = "external_links"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(50))
+    url: Mapped[str] = mapped_column(String(500))
+    icon: Mapped[str] = mapped_column(String(50), default="link")
+    custom_icon: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)  # Filename of uploaded icon
+    sort_order: Mapped[int] = mapped_column(Integer, default=0)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )

+ 58 - 0
backend/app/schemas/external_link.py

@@ -0,0 +1,58 @@
+from datetime import datetime
+from pydantic import BaseModel, Field, field_validator
+
+
+class ExternalLinkBase(BaseModel):
+    """Base schema for external links."""
+
+    name: str = Field(..., min_length=1, max_length=50, description="Display name for the link")
+    url: str = Field(..., min_length=1, max_length=500, description="External URL")
+    icon: str = Field(default="link", max_length=50, description="Lucide icon name")
+
+    @field_validator("url")
+    @classmethod
+    def validate_url(cls, v: str) -> str:
+        """Validate URL format."""
+        if not v.startswith(("http://", "https://")):
+            raise ValueError("URL must start with http:// or https://")
+        return v
+
+
+class ExternalLinkCreate(ExternalLinkBase):
+    """Schema for creating an external link."""
+
+    pass
+
+
+class ExternalLinkUpdate(BaseModel):
+    """Schema for updating an external link (all fields optional)."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=50)
+    url: str | None = Field(default=None, min_length=1, max_length=500)
+    icon: str | None = Field(default=None, max_length=50)
+
+    @field_validator("url")
+    @classmethod
+    def validate_url(cls, v: str | None) -> str | None:
+        """Validate URL format."""
+        if v is not None and not v.startswith(("http://", "https://")):
+            raise ValueError("URL must start with http:// or https://")
+        return v
+
+
+class ExternalLinkResponse(ExternalLinkBase):
+    """Response schema for external links."""
+
+    id: int
+    custom_icon: str | None = None
+    sort_order: int
+    created_at: datetime
+    updated_at: datetime
+
+    model_config = {"from_attributes": True}
+
+
+class ExternalLinkReorder(BaseModel):
+    """Schema for reordering external links."""
+
+    ids: list[int] = Field(..., description="List of link IDs in desired order")

+ 3 - 0
build_docker.sh

@@ -0,0 +1,3 @@
+#!/bin/sh
+
+sudo DOCKER_BUILDKIT=0 docker compose build

+ 16 - 0
docker-compose.yml

@@ -0,0 +1,16 @@
+services:
+  bambuddy:
+    build: .
+    container_name: bambuddy
+    ports:
+      - "8000:8000"
+    volumes:
+      - bambuddy_data:/app/data
+      - bambuddy_logs:/app/logs
+    environment:
+      - TZ=Europe/Berlin
+    restart: unless-stopped
+
+volumes:
+  bambuddy_data:
+  bambuddy_logs:

+ 2 - 0
frontend/src/App.tsx

@@ -9,6 +9,7 @@ import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { CameraPage } from './pages/CameraPage';
+import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -46,6 +47,7 @@ function App() {
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="settings" element={<SettingsPage />} />
+                  <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
               </Routes>
             </BrowserRouter>

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

@@ -881,6 +881,30 @@ export interface MaintenanceSummary {
   }>;
 }
 
+// External Links (sidebar)
+export interface ExternalLink {
+  id: number;
+  name: string;
+  url: string;
+  icon: string;
+  custom_icon: string | null;
+  sort_order: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ExternalLinkCreate {
+  name: string;
+  url: string;
+  icon: string;
+}
+
+export interface ExternalLinkUpdate {
+  name?: string;
+  url?: string;
+  icon?: string;
+}
+
 // API functions
 export const api = {
   // Printers
@@ -1506,4 +1530,41 @@ export const api = {
     `${API_BASE}/printers/${printerId}/camera/snapshot`,
   testCameraConnection: (printerId: number) =>
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
+
+  // External Links
+  getExternalLinks: () => request<ExternalLink[]>('/external-links/'),
+  getExternalLink: (id: number) => request<ExternalLink>(`/external-links/${id}`),
+  createExternalLink: (data: ExternalLinkCreate) =>
+    request<ExternalLink>('/external-links/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateExternalLink: (id: number, data: ExternalLinkUpdate) =>
+    request<ExternalLink>(`/external-links/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteExternalLink: (id: number) =>
+    request<{ message: string }>(`/external-links/${id}`, { method: 'DELETE' }),
+  reorderExternalLinks: (ids: number[]) =>
+    request<ExternalLink[]>('/external-links/reorder', {
+      method: 'PUT',
+      body: JSON.stringify({ ids }),
+    }),
+  uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  deleteExternalLinkIcon: (id: number) =>
+    request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
+  getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`,
 };

+ 299 - 0
frontend/src/components/AddExternalLinkModal.tsx

@@ -0,0 +1,299 @@
+import { useState, useEffect, useRef } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
+import { Button } from './Button';
+import { IconPicker, getIconByName } from './IconPicker';
+import { useTheme } from '../contexts/ThemeContext';
+
+interface AddExternalLinkModalProps {
+  link?: ExternalLink | null;
+  onClose: () => void;
+}
+
+export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
+  const queryClient = useQueryClient();
+  const { theme } = useTheme();
+  const isEditing = !!link;
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const [name, setName] = useState(link?.name || '');
+  const [url, setUrl] = useState(link?.url || '');
+  const [icon, setIcon] = useState(link?.icon || 'link');
+  const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);
+  const [customIconPreview, setCustomIconPreview] = useState<string | null>(
+    link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
+  );
+  const [pendingIconFile, setPendingIconFile] = useState<File | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Create mutation
+  const createMutation = useMutation({
+    mutationFn: async (data: ExternalLinkCreate) => {
+      const created = await api.createExternalLink(data);
+      // If there's a pending icon file, upload it
+      if (pendingIconFile) {
+        return await api.uploadExternalLinkIcon(created.id, pendingIconFile);
+      }
+      return created;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: async (data: ExternalLinkUpdate) => {
+      let updated = await api.updateExternalLink(link!.id, data);
+      // Handle icon changes
+      if (pendingIconFile) {
+        // Upload new icon
+        updated = await api.uploadExternalLinkIcon(link!.id, pendingIconFile);
+      } else if (!useCustomIcon && link?.custom_icon) {
+        // Remove custom icon if switching to preset
+        updated = await api.deleteExternalLinkIcon(link!.id);
+      }
+      return updated;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (file) {
+      // Validate file type
+      const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml', 'image/webp', 'image/x-icon'];
+      if (!validTypes.includes(file.type)) {
+        setError('Please select a valid image file (PNG, JPG, GIF, SVG, WebP, or ICO)');
+        return;
+      }
+
+      // Validate file size (max 1MB)
+      if (file.size > 1024 * 1024) {
+        setError('Image file must be less than 1MB');
+        return;
+      }
+
+      setPendingIconFile(file);
+      setUseCustomIcon(true);
+
+      // Create preview
+      const reader = new FileReader();
+      reader.onload = (e) => {
+        setCustomIconPreview(e.target?.result as string);
+      };
+      reader.readAsDataURL(file);
+    }
+  };
+
+  const handleRemoveCustomIcon = () => {
+    setPendingIconFile(null);
+    setCustomIconPreview(null);
+    setUseCustomIcon(false);
+    if (fileInputRef.current) {
+      fileInputRef.current.value = '';
+    }
+  };
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    setError(null);
+
+    if (!name.trim()) {
+      setError('Name is required');
+      return;
+    }
+
+    if (!url.trim()) {
+      setError('URL is required');
+      return;
+    }
+
+    // Validate URL
+    if (!url.startsWith('http://') && !url.startsWith('https://')) {
+      setError('URL must start with http:// or https://');
+      return;
+    }
+
+    const data = {
+      name: name.trim(),
+      url: url.trim(),
+      icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
+    };
+
+    if (isEditing) {
+      updateMutation.mutate(data);
+    } else {
+      createMutation.mutate(data);
+    }
+  };
+
+  const isPending = createMutation.isPending || updateMutation.isPending;
+  const PresetIcon = getIconByName(icon);
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
+              {useCustomIcon && customIconPreview ? (
+                <img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
+              ) : (
+                <PresetIcon className="w-5 h-5" />
+              )}
+            </div>
+            <h2 className="text-lg font-semibold text-white">
+              {isEditing ? 'Edit Link' : 'Add External Link'}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Form */}
+        <form onSubmit={handleSubmit} className="p-6 space-y-4">
+          {error && (
+            <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+              {error}
+            </div>
+          )}
+
+          {/* Name */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Name *</label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="My Link"
+              maxLength={50}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            />
+          </div>
+
+          {/* URL */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">URL *</label>
+            <input
+              type="text"
+              value={url}
+              onChange={(e) => setUrl(e.target.value)}
+              placeholder="https://example.com"
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            />
+          </div>
+
+          {/* Icon Section */}
+          <div className="space-y-3">
+            <label className="block text-sm text-bambu-gray">Icon</label>
+
+            {/* Custom Icon Upload */}
+            <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+              <div className="flex items-center justify-between mb-2">
+                <span className="text-sm text-white">Custom Icon</span>
+                <input
+                  ref={fileInputRef}
+                  type="file"
+                  accept="image/png,image/jpeg,image/gif,image/svg+xml,image/webp,image/x-icon"
+                  className="hidden"
+                  onChange={handleFileSelect}
+                />
+                {useCustomIcon && customIconPreview ? (
+                  <div className="flex items-center gap-2">
+                    <img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
+                    <button
+                      type="button"
+                      onClick={handleRemoveCustomIcon}
+                      className="p-1 text-red-400 hover:text-red-300 transition-colors"
+                      title="Remove custom icon"
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </button>
+                  </div>
+                ) : (
+                  <Button
+                    type="button"
+                    variant="secondary"
+                    size="sm"
+                    onClick={() => fileInputRef.current?.click()}
+                  >
+                    <Upload className="w-4 h-4" />
+                    Upload
+                  </Button>
+                )}
+              </div>
+              <p className="text-xs text-bambu-gray">
+                PNG, JPG, GIF, SVG, WebP, or ICO. Max 1MB.
+              </p>
+            </div>
+
+            {/* Preset Icon Picker */}
+            {!useCustomIcon && (
+              <div>
+                <span className="text-sm text-bambu-gray block mb-2">Or choose a preset icon</span>
+                <IconPicker value={icon} onChange={setIcon} />
+              </div>
+            )}
+          </div>
+
+          {/* Actions */}
+          <div className="flex gap-3 pt-2">
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={onClose}
+              className="flex-1"
+            >
+              Cancel
+            </Button>
+            <Button
+              type="submit"
+              disabled={isPending}
+              className="flex-1"
+            >
+              {isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Save className="w-4 h-4" />
+              )}
+              {isEditing ? 'Save' : 'Add'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 3 - 3
frontend/src/components/Button.tsx

@@ -26,9 +26,9 @@ export function Button({
   };
 
   const sizes = {
-    sm: 'px-3 py-1.5 text-sm gap-1.5',
-    md: 'px-4 py-2 text-sm gap-2',
-    lg: 'px-6 py-3 text-base gap-2',
+    sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0',
+    md: 'px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0',
+    lg: 'px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0',
   };
 
   return (

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

@@ -93,7 +93,7 @@ function SortableWidget({
             className="cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
             title="Drag to reorder"
           >
-            <GripVertical className="w-4 h-4 text-bambu-gray" />
+            <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
           </button>
           <h3 className="text-sm font-medium text-white">{title}</h3>
         </div>

+ 187 - 0
frontend/src/components/ExternalLinksSettings.tsx

@@ -0,0 +1,187 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon } from 'lucide-react';
+import { api } from '../api/client';
+import type { ExternalLink } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { AddExternalLinkModal } from './AddExternalLinkModal';
+import { ConfirmModal } from './ConfirmModal';
+import { getIconByName } from './IconPicker';
+
+export function ExternalLinksSettings() {
+  const queryClient = useQueryClient();
+  const [showAddModal, setShowAddModal] = useState(false);
+  const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
+  const [deletingLink, setDeletingLink] = useState<ExternalLink | null>(null);
+  const [draggedId, setDraggedId] = useState<number | null>(null);
+
+  // Fetch external links
+  const { data: links, isLoading } = useQuery({
+    queryKey: ['external-links'],
+    queryFn: api.getExternalLinks,
+  });
+
+  // Delete mutation
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteExternalLink(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+    },
+  });
+
+  // Reorder mutation
+  const reorderMutation = useMutation({
+    mutationFn: (ids: number[]) => api.reorderExternalLinks(ids),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+    },
+  });
+
+  const handleDragStart = (e: React.DragEvent, id: number) => {
+    setDraggedId(id);
+    e.dataTransfer.effectAllowed = 'move';
+  };
+
+  const handleDragOver = (e: React.DragEvent) => {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = 'move';
+  };
+
+  const handleDrop = (e: React.DragEvent, targetId: number) => {
+    e.preventDefault();
+    if (draggedId === null || draggedId === targetId || !links) return;
+
+    const currentIds = links.map((l) => l.id);
+    const draggedIndex = currentIds.indexOf(draggedId);
+    const targetIndex = currentIds.indexOf(targetId);
+
+    if (draggedIndex === -1 || targetIndex === -1) return;
+
+    // Reorder
+    const newIds = [...currentIds];
+    newIds.splice(draggedIndex, 1);
+    newIds.splice(targetIndex, 0, draggedId);
+
+    reorderMutation.mutate(newIds);
+    setDraggedId(null);
+  };
+
+  const handleDelete = (link: ExternalLink) => {
+    setDeletingLink(link);
+  };
+
+  const confirmDelete = () => {
+    if (deletingLink) {
+      deleteMutation.mutate(deletingLink.id);
+      setDeletingLink(null);
+    }
+  };
+
+  return (
+    <>
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <Link2 className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-lg font-semibold text-white">Sidebar Links</h2>
+            </div>
+            <Button size="sm" onClick={() => setShowAddModal(true)}>
+              <Plus className="w-4 h-4" />
+              Add Link
+            </Button>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <p className="text-sm text-bambu-gray mb-4">
+            Add external links to the sidebar navigation. Drag to reorder.
+          </p>
+
+          {isLoading ? (
+            <div className="flex justify-center py-8">
+              <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+            </div>
+          ) : links && links.length > 0 ? (
+            <div className="space-y-2">
+              {links.map((link) => {
+                const Icon = getIconByName(link.icon);
+                return (
+                  <div
+                    key={link.id}
+                    draggable
+                    onDragStart={(e) => handleDragStart(e, link.id)}
+                    onDragOver={handleDragOver}
+                    onDrop={(e) => handleDrop(e, link.id)}
+                    className={`flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
+                      draggedId === link.id ? 'opacity-50' : ''
+                    }`}
+                  >
+                    <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0" />
+                    <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
+                      <Icon className="w-4 h-4" />
+                    </div>
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center gap-2">
+                        <span className="text-white font-medium truncate">{link.name}</span>
+                        <ExternalLinkIcon className="w-3 h-3 text-bambu-gray flex-shrink-0" />
+                      </div>
+                      <span className="text-sm text-bambu-gray truncate block">{link.url}</span>
+                    </div>
+                    <div className="flex items-center gap-1 flex-shrink-0">
+                      <button
+                        onClick={() => setEditingLink(link)}
+                        className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+                        title="Edit"
+                      >
+                        <Pencil className="w-4 h-4" />
+                      </button>
+                      <button
+                        onClick={() => handleDelete(link)}
+                        disabled={deleteMutation.isPending}
+                        className="p-2 rounded-lg hover:bg-red-500/20 text-bambu-gray hover:text-red-400 transition-colors disabled:opacity-50"
+                        title="Delete"
+                      >
+                        <Trash2 className="w-4 h-4" />
+                      </button>
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          ) : (
+            <div className="text-center py-8 text-bambu-gray">
+              <Link2 className="w-8 h-8 mx-auto mb-2 opacity-50" />
+              <p>No external links configured</p>
+              <p className="text-sm">Click "Add Link" to add one</p>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Add/Edit Modal */}
+      {(showAddModal || editingLink) && (
+        <AddExternalLinkModal
+          link={editingLink}
+          onClose={() => {
+            setShowAddModal(false);
+            setEditingLink(null);
+          }}
+        />
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deletingLink && (
+        <ConfirmModal
+          title="Delete Link"
+          message={`Are you sure you want to delete "${deletingLink.name}"? This action cannot be undone.`}
+          confirmText="Delete"
+          cancelText="Cancel"
+          variant="danger"
+          onConfirm={confirmDelete}
+          onCancel={() => setDeletingLink(null)}
+        />
+      )}
+    </>
+  );
+}

+ 132 - 0
frontend/src/components/IconPicker.tsx

@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import {
+  Globe,
+  Link,
+  ExternalLink,
+  Book,
+  FileText,
+  Home,
+  Star,
+  Heart,
+  Bookmark,
+  ShoppingCart,
+  Music,
+  Video,
+  Image,
+  Camera,
+  Map,
+  Compass,
+  Coffee,
+  Gift,
+  Wrench,
+  Zap,
+  Cloud,
+  Database,
+  Folder,
+  Mail,
+  Phone,
+  User,
+  Users,
+  Server,
+  Terminal,
+  Code,
+  type LucideIcon,
+} from 'lucide-react';
+
+// Available icons for external links
+export const AVAILABLE_ICONS: { name: string; icon: LucideIcon }[] = [
+  { name: 'globe', icon: Globe },
+  { name: 'link', icon: Link },
+  { name: 'external-link', icon: ExternalLink },
+  { name: 'book', icon: Book },
+  { name: 'file-text', icon: FileText },
+  { name: 'home', icon: Home },
+  { name: 'star', icon: Star },
+  { name: 'heart', icon: Heart },
+  { name: 'bookmark', icon: Bookmark },
+  { name: 'shopping-cart', icon: ShoppingCart },
+  { name: 'music', icon: Music },
+  { name: 'video', icon: Video },
+  { name: 'image', icon: Image },
+  { name: 'camera', icon: Camera },
+  { name: 'map', icon: Map },
+  { name: 'compass', icon: Compass },
+  { name: 'coffee', icon: Coffee },
+  { name: 'gift', icon: Gift },
+  { name: 'wrench', icon: Wrench },
+  { name: 'zap', icon: Zap },
+  { name: 'cloud', icon: Cloud },
+  { name: 'database', icon: Database },
+  { name: 'folder', icon: Folder },
+  { name: 'mail', icon: Mail },
+  { name: 'phone', icon: Phone },
+  { name: 'user', icon: User },
+  { name: 'users', icon: Users },
+  { name: 'server', icon: Server },
+  { name: 'terminal', icon: Terminal },
+  { name: 'code', icon: Code },
+];
+
+// Helper to get icon component by name
+export function getIconByName(name: string): LucideIcon {
+  const found = AVAILABLE_ICONS.find((i) => i.name === name);
+  return found?.icon || Link;
+}
+
+interface IconPickerProps {
+  value: string;
+  onChange: (value: string) => void;
+}
+
+export function IconPicker({ value, onChange }: IconPickerProps) {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const SelectedIcon = getIconByName(value);
+
+  return (
+    <div className="relative">
+      <button
+        type="button"
+        onClick={() => setIsOpen(!isOpen)}
+        className="flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white hover:border-bambu-gray focus:border-bambu-green focus:outline-none w-full"
+      >
+        <SelectedIcon className="w-5 h-5" />
+        <span className="text-sm text-bambu-gray flex-1 text-left">{value}</span>
+      </button>
+
+      {isOpen && (
+        <>
+          {/* Backdrop */}
+          <div
+            className="fixed inset-0 z-40"
+            onClick={() => setIsOpen(false)}
+          />
+
+          {/* Dropdown */}
+          <div className="absolute z-50 mt-1 w-full max-h-64 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg">
+            <div className="grid grid-cols-5 gap-1 p-2">
+              {AVAILABLE_ICONS.map(({ name, icon: Icon }) => (
+                <button
+                  key={name}
+                  type="button"
+                  onClick={() => {
+                    onChange(name);
+                    setIsOpen(false);
+                  }}
+                  className={`p-2 rounded-lg transition-colors flex items-center justify-center ${
+                    value === name
+                      ? 'bg-bambu-green text-white'
+                      : 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  title={name}
+                >
+                  <Icon className="w-5 h-5" />
+                </button>
+              ))}
+            </div>
+          </div>
+        </>
+      )}
+    </div>
+  );
+}

+ 289 - 125
frontend/src/components/Layout.tsx

@@ -1,11 +1,13 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, Menu, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { useQuery } from '@tanstack/react-query';
 import { api } from '../api/client';
+import { getIconByName } from './IconPicker';
+import { useIsMobile } from '../hooks/useIsMobile';
 
 interface NavItem {
   id: string;
@@ -24,36 +26,27 @@ export const defaultNavItems: NavItem[] = [
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 
-// Get ordered nav items from localStorage
-function getOrderedNavItems(): NavItem[] {
+// Get unified sidebar order from localStorage
+function getSidebarOrder(): string[] {
   const stored = localStorage.getItem('sidebarOrder');
   if (stored) {
     try {
-      const order: string[] = JSON.parse(stored);
-      const itemMap = new Map(defaultNavItems.map(item => [item.id, item]));
-      const ordered: NavItem[] = [];
-      for (const id of order) {
-        const item = itemMap.get(id);
-        if (item) {
-          ordered.push(item);
-          itemMap.delete(id);
-        }
-      }
-      // Add any new items that weren't in the stored order
-      for (const item of itemMap.values()) {
-        ordered.push(item);
-      }
-      return ordered;
+      return JSON.parse(stored);
     } catch {
-      return defaultNavItems;
+      return defaultNavItems.map(i => i.id);
     }
   }
-  return defaultNavItems;
+  return defaultNavItems.map(i => i.id);
 }
 
-// Save nav item order to localStorage
-function saveNavOrder(items: NavItem[]) {
-  localStorage.setItem('sidebarOrder', JSON.stringify(items.map(i => i.id)));
+// Save unified sidebar order to localStorage
+function saveSidebarOrder(order: string[]) {
+  localStorage.setItem('sidebarOrder', JSON.stringify(order));
+}
+
+// Check if an ID is an external link
+function isExternalLinkId(id: string): boolean {
+  return id.startsWith('ext-');
 }
 
 // Get default view from localStorage
@@ -71,14 +64,16 @@ export function Layout() {
   const location = useLocation();
   const { theme, toggleTheme } = useTheme();
   const { t } = useTranslation();
+  const isMobile = useIsMobile();
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
     const stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
   });
+  const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
   const [showShortcuts, setShowShortcuts] = useState(false);
-  const [navItems, setNavItems] = useState<NavItem[]>(getOrderedNavItems);
-  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
-  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
+  const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
+  const [draggedId, setDraggedId] = useState<string | null>(null);
+  const [dragOverId, setDragOverId] = useState<string | null>(null);
   const hasRedirected = useRef(false);
   const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
     sessionStorage.getItem('dismissedUpdateVersion')
@@ -105,6 +100,101 @@ export function Layout() {
     refetchInterval: 60 * 60 * 1000, // Check every hour
   });
 
+  // Fetch external links for sidebar
+  const { data: externalLinks } = useQuery({
+    queryKey: ['external-links'],
+    queryFn: api.getExternalLinks,
+  });
+
+  // Build the unified sidebar items list
+  const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
+  const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
+
+  // Compute the ordered sidebar: include stored order + any new items
+  const orderedSidebarIds = (() => {
+    const result: string[] = [];
+    const seen = new Set<string>();
+
+    // Add items in stored order
+    for (const id of sidebarOrder) {
+      if (navItemsMap.has(id) || extLinksMap.has(id)) {
+        result.push(id);
+        seen.add(id);
+      }
+    }
+
+    // Add any new internal nav items not in stored order
+    for (const item of defaultNavItems) {
+      if (!seen.has(item.id)) {
+        result.push(item.id);
+        seen.add(item.id);
+      }
+    }
+
+    // Add any new external links not in stored order
+    for (const link of externalLinks || []) {
+      const extId = `ext-${link.id}`;
+      if (!seen.has(extId)) {
+        result.push(extId);
+        seen.add(extId);
+      }
+    }
+
+    return result;
+  })();
+
+  // Unified drag handlers
+  const handleDragStart = (e: React.DragEvent, id: string) => {
+    setDraggedId(id);
+    e.dataTransfer.effectAllowed = 'move';
+    e.dataTransfer.setData('text/plain', id);
+  };
+
+  const handleDragOver = (e: React.DragEvent, id: string) => {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = 'move';
+    setDragOverId(id);
+  };
+
+  const handleDragLeave = () => {
+    setDragOverId(null);
+  };
+
+  const handleDrop = (e: React.DragEvent, targetId: string) => {
+    e.preventDefault();
+    if (draggedId === null || draggedId === targetId) {
+      setDraggedId(null);
+      setDragOverId(null);
+      return;
+    }
+
+    const currentOrder = [...orderedSidebarIds];
+    const draggedIndex = currentOrder.indexOf(draggedId);
+    const targetIndex = currentOrder.indexOf(targetId);
+
+    if (draggedIndex === -1 || targetIndex === -1) {
+      setDraggedId(null);
+      setDragOverId(null);
+      return;
+    }
+
+    // Reorder
+    currentOrder.splice(draggedIndex, 1);
+    currentOrder.splice(targetIndex, 0, draggedId);
+
+    // Save to localStorage and update state
+    setSidebarOrder(currentOrder);
+    saveSidebarOrder(currentOrder);
+
+    setDraggedId(null);
+    setDragOverId(null);
+  };
+
+  const handleDragEnd = () => {
+    setDraggedId(null);
+    setDragOverId(null);
+  };
+
   // Show update banner if update available and not dismissed for this version
   const showUpdateBanner = updateCheck?.update_available &&
     updateCheck.latest_version &&
@@ -132,45 +222,12 @@ export function Layout() {
     localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
   }, [sidebarExpanded]);
 
-  // Drag and drop handlers
-  const handleDragStart = (e: React.DragEvent, index: number) => {
-    setDraggedIndex(index);
-    e.dataTransfer.effectAllowed = 'move';
-    e.dataTransfer.setData('text/plain', String(index));
-  };
-
-  const handleDragOver = (e: React.DragEvent, index: number) => {
-    e.preventDefault();
-    e.dataTransfer.dropEffect = 'move';
-    setDragOverIndex(index);
-  };
-
-  const handleDragLeave = () => {
-    setDragOverIndex(null);
-  };
-
-  const handleDrop = (e: React.DragEvent, dropIndex: number) => {
-    e.preventDefault();
-    if (draggedIndex === null || draggedIndex === dropIndex) {
-      setDraggedIndex(null);
-      setDragOverIndex(null);
-      return;
+  // Close mobile drawer on navigation
+  useEffect(() => {
+    if (isMobile) {
+      setMobileDrawerOpen(false);
     }
-
-    const newItems = [...navItems];
-    const [draggedItem] = newItems.splice(draggedIndex, 1);
-    newItems.splice(dropIndex, 0, draggedItem);
-
-    setNavItems(newItems);
-    saveNavOrder(newItems);
-    setDraggedIndex(null);
-    setDragOverIndex(null);
-  };
-
-  const handleDragEnd = () => {
-    setDraggedIndex(null);
-    setDragOverIndex(null);
-  };
+  }, [location.pathname, isMobile]);
 
   // Global keyboard shortcuts for navigation
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -180,13 +237,17 @@ export function Layout() {
       return;
     }
 
-    // Number keys for navigation (1-6) - follows sidebar order
+    // Number keys for navigation (1-9) - follows sidebar order for internal nav items only
     if (!e.metaKey && !e.ctrlKey && !e.altKey) {
       const keyNum = parseInt(e.key);
-      if (keyNum >= 1 && keyNum <= navItems.length) {
-        e.preventDefault();
-        navigate(navItems[keyNum - 1].to);
-        return;
+      const internalItems = orderedSidebarIds.filter(id => !isExternalLinkId(id));
+      if (keyNum >= 1 && keyNum <= internalItems.length) {
+        const navItem = navItemsMap.get(internalItems[keyNum - 1]);
+        if (navItem) {
+          e.preventDefault();
+          navigate(navItem.to);
+          return;
+        }
       }
 
       switch (e.key) {
@@ -199,7 +260,7 @@ export function Layout() {
           break;
       }
     }
-  }, [navigate, navItems]);
+  }, [navigate, orderedSidebarIds, navItemsMap]);
 
   useEffect(() => {
     document.addEventListener('keydown', handleKeyDown);
@@ -208,77 +269,170 @@ export function Layout() {
 
   return (
     <div className="flex min-h-screen">
-      {/* Sidebar */}
+      {/* Mobile Header */}
+      {isMobile && (
+        <header className="fixed top-0 left-0 right-0 z-40 h-14 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-4">
+          <button
+            onClick={() => setMobileDrawerOpen(true)}
+            className="p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+            aria-label="Open menu"
+          >
+            <Menu className="w-6 h-6 text-white" />
+          </button>
+          <img
+            src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
+            alt="Bambuddy"
+            className="h-8 ml-3"
+          />
+        </header>
+      )}
+
+      {/* Mobile Drawer Backdrop */}
+      {isMobile && mobileDrawerOpen && (
+        <div
+          className="fixed inset-0 bg-black/60 z-40 transition-opacity"
+          onClick={() => setMobileDrawerOpen(false)}
+        />
+      )}
+
+      {/* Sidebar / Mobile Drawer */}
       <aside
-        className={`${sidebarExpanded ? 'w-64' : 'w-16'} bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col fixed inset-y-0 left-0 z-30 transition-all duration-300`}
+        className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
+          isMobile
+            ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
+            : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
+        }`}
       >
         {/* Logo */}
-        <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${sidebarExpanded ? 'p-4' : 'p-2'}`}>
+        <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
           <img
             src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
             alt="Bambuddy"
-            className={sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
+            className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
           />
         </div>
 
         {/* Navigation */}
         <nav className="flex-1 p-2">
           <ul className="space-y-2">
-            {navItems.map(({ id, to, icon: Icon, labelKey }, index) => (
-              <li
-                key={id}
-                draggable
-                onDragStart={(e) => handleDragStart(e, index)}
-                onDragOver={(e) => handleDragOver(e, index)}
-                onDragLeave={handleDragLeave}
-                onDrop={(e) => handleDrop(e, index)}
-                onDragEnd={handleDragEnd}
-                className={`relative ${
-                  draggedIndex === index ? 'opacity-50' : ''
-                } ${
-                  dragOverIndex === index && draggedIndex !== index
-                    ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
-                    : ''
-                }`}
-              >
-                <NavLink
-                  to={to}
-                  className={({ isActive }) =>
-                    `flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
-                      isActive
-                        ? 'bg-bambu-green text-white'
-                        : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
-                    }`
-                  }
-                  title={!sidebarExpanded ? t(labelKey) : undefined}
-                >
-                  {sidebarExpanded && (
-                    <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
-                  )}
-                  <Icon className="w-5 h-5 flex-shrink-0" />
-                  {sidebarExpanded && <span>{t(labelKey)}</span>}
-                </NavLink>
-              </li>
-            ))}
+            {orderedSidebarIds.map((id) => {
+              const isExternal = isExternalLinkId(id);
+
+              if (isExternal) {
+                // Render external link
+                const link = extLinksMap.get(id);
+                if (!link) return null;
+
+                const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
+                return (
+                  <li
+                    key={id}
+                    draggable
+                    onDragStart={(e) => handleDragStart(e, id)}
+                    onDragOver={(e) => handleDragOver(e, id)}
+                    onDragLeave={handleDragLeave}
+                    onDrop={(e) => handleDrop(e, id)}
+                    onDragEnd={handleDragEnd}
+                    className={`relative ${
+                      draggedId === id ? 'opacity-50' : ''
+                    } ${
+                      dragOverId === id && draggedId !== id
+                        ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
+                        : ''
+                    }`}
+                  >
+                    <NavLink
+                      to={`/external/${link.id}`}
+                      className={({ isActive }) =>
+                        `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                          isActive
+                            ? 'bg-bambu-green text-white'
+                            : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
+                        }`
+                      }
+                      title={!isMobile && !sidebarExpanded ? link.name : undefined}
+                    >
+                      {sidebarExpanded && !isMobile && (
+                        <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
+                      )}
+                      {link.custom_icon ? (
+                        <img
+                          src={`/api/v1/external-links/${link.id}/icon`}
+                          alt=""
+                          className={`w-5 h-5 flex-shrink-0 ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
+                        />
+                      ) : (
+                        LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
+                      )}
+                      {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
+                    </NavLink>
+                  </li>
+                );
+              } else {
+                // Render internal nav item
+                const navItem = navItemsMap.get(id);
+                if (!navItem) return null;
+
+                const { to, icon: Icon, labelKey } = navItem;
+                return (
+                  <li
+                    key={id}
+                    draggable
+                    onDragStart={(e) => handleDragStart(e, id)}
+                    onDragOver={(e) => handleDragOver(e, id)}
+                    onDragLeave={handleDragLeave}
+                    onDrop={(e) => handleDrop(e, id)}
+                    onDragEnd={handleDragEnd}
+                    className={`relative ${
+                      draggedId === id ? 'opacity-50' : ''
+                    } ${
+                      dragOverId === id && draggedId !== id
+                        ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
+                        : ''
+                    }`}
+                  >
+                    <NavLink
+                      to={to}
+                      className={({ isActive }) =>
+                        `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                          isActive
+                            ? 'bg-bambu-green text-white'
+                            : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
+                        }`
+                      }
+                      title={!isMobile && !sidebarExpanded ? t(labelKey) : undefined}
+                    >
+                      {sidebarExpanded && !isMobile && (
+                        <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
+                      )}
+                      <Icon className="w-5 h-5 flex-shrink-0" />
+                      {(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
+                    </NavLink>
+                  </li>
+                );
+              }
+            })}
           </ul>
         </nav>
 
-        {/* Collapse toggle */}
-        <button
-          onClick={() => setSidebarExpanded(!sidebarExpanded)}
-          className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
-          title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
-        >
-          {sidebarExpanded ? (
-            <ChevronLeft className="w-5 h-5" />
-          ) : (
-            <ChevronRight className="w-5 h-5" />
-          )}
-        </button>
+        {/* Collapse toggle - hide on mobile */}
+        {!isMobile && (
+          <button
+            onClick={() => setSidebarExpanded(!sidebarExpanded)}
+            className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
+            title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
+          >
+            {sidebarExpanded ? (
+              <ChevronLeft className="w-5 h-5" />
+            ) : (
+              <ChevronRight className="w-5 h-5" />
+            )}
+          </button>
+        )}
 
         {/* Footer */}
         <div className="p-2 border-t border-bambu-dark-tertiary">
-          {sidebarExpanded ? (
+          {isMobile || sidebarExpanded ? (
             <div className="flex items-center justify-between px-2">
               <div className="flex items-center gap-2">
                 <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
@@ -359,7 +513,9 @@ export function Layout() {
       </aside>
 
       {/* Main content */}
-      <main className={`flex-1 bg-bambu-dark overflow-auto ${sidebarExpanded ? 'ml-64' : 'ml-16'} transition-all duration-300`}>
+      <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
+        isMobile ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
+      }`}>
         {/* Persistent update banner */}
         {showUpdateBanner && (
           <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
@@ -391,7 +547,15 @@ export function Layout() {
       </main>
 
       {/* Keyboard Shortcuts Modal */}
-      {showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} navItems={navItems} />}
+      {showShortcuts && (
+        <KeyboardShortcutsModal
+          onClose={() => setShowShortcuts(false)}
+          navItems={orderedSidebarIds
+            .filter(id => !isExternalLinkId(id))
+            .map(id => navItemsMap.get(id)!)
+            .filter(Boolean)}
+        />
+      )}
     </div>
   );
 }

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

@@ -20,7 +20,7 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
       aria-checked={checked}
       disabled={disabled}
       onClick={handleClick}
-      className={`relative inline-flex w-9 h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
+      className={`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
         disabled
           ? 'bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50'
           : checked
@@ -29,7 +29,7 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
       }`}
     >
       <span
-        className={`pointer-events-none absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${
+        className={`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${
           checked ? 'translate-x-4' : 'translate-x-0'
         }`}
       />

+ 26 - 0
frontend/src/hooks/useIsMobile.ts

@@ -0,0 +1,26 @@
+import { useState, useEffect } from 'react';
+
+const MOBILE_BREAKPOINT = 768; // md breakpoint
+
+export function useIsMobile(): boolean {
+  const [isMobile, setIsMobile] = useState(() =>
+    typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false
+  );
+
+  useEffect(() => {
+    const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+
+    const handleChange = (e: MediaQueryListEvent) => {
+      setIsMobile(e.matches);
+    };
+
+    // Set initial value
+    setIsMobile(mediaQuery.matches);
+
+    // Modern browsers support addEventListener
+    mediaQuery.addEventListener('change', handleChange);
+    return () => mediaQuery.removeEventListener('change', handleChange);
+  }, []);
+
+  return isMobile;
+}

+ 46 - 0
frontend/src/hooks/useLongPress.ts

@@ -0,0 +1,46 @@
+import { useCallback, useRef } from 'react';
+
+interface LongPressOptions {
+  onLongPress: (e: React.TouchEvent | React.MouseEvent) => void;
+  onClick?: () => void;
+  delay?: number;
+}
+
+export function useLongPress({ onLongPress, onClick, delay = 500 }: LongPressOptions) {
+  const timeoutRef = useRef<number | null>(null);
+  const targetRef = useRef<EventTarget | null>(null);
+  const longPressTriggered = useRef(false);
+
+  const start = useCallback(
+    (e: React.TouchEvent | React.MouseEvent) => {
+      longPressTriggered.current = false;
+      targetRef.current = e.target;
+      timeoutRef.current = window.setTimeout(() => {
+        longPressTriggered.current = true;
+        onLongPress(e);
+      }, delay);
+    },
+    [onLongPress, delay]
+  );
+
+  const clear = useCallback(
+    (e: React.TouchEvent | React.MouseEvent, shouldTriggerClick = true) => {
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current);
+        timeoutRef.current = null;
+      }
+      if (shouldTriggerClick && !longPressTriggered.current && onClick && targetRef.current === e.target) {
+        onClick();
+      }
+    },
+    [onClick]
+  );
+
+  return {
+    onMouseDown: start,
+    onMouseUp: (e: React.MouseEvent) => clear(e, true),
+    onMouseLeave: (e: React.MouseEvent) => clear(e, false),
+    onTouchStart: start,
+    onTouchEnd: (e: React.TouchEvent) => clear(e, true),
+  };
+}

+ 38 - 0
frontend/src/index.css

@@ -143,3 +143,41 @@ body {
     #333 4px
   );
 }
+
+/* Touch manipulation to prevent zoom on double-tap */
+.touch-manipulation {
+  touch-action: manipulation;
+}
+
+/* Safe area insets for notched devices */
+.safe-area-bottom {
+  padding-bottom: env(safe-area-inset-bottom, 0);
+}
+
+.safe-area-top {
+  padding-top: env(safe-area-inset-top, 0);
+}
+
+/* Hide scrollbar but keep functionality */
+.scrollbar-hide {
+  -ms-overflow-style: none;
+  scrollbar-width: none;
+}
+
+.scrollbar-hide::-webkit-scrollbar {
+  display: none;
+}
+
+/* Mobile drawer animation */
+@keyframes slide-in-left {
+  from {
+    transform: translateX(-100%);
+  }
+  to {
+    transform: translateX(0);
+  }
+}
+
+.animate-slide-in-left {
+  animation: slide-in-left 0.3s ease-out;
+}

+ 37 - 16
frontend/src/pages/ArchivesPage.tsx

@@ -35,8 +35,10 @@ import {
   Camera,
   FileText,
   FileCode,
+  MoreVertical,
 } from 'lucide-react';
 import { api } from '../api/client';
+import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -93,6 +95,7 @@ function ArchiveCard({
 }) {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const isMobile = useIsMobile();
   const [showViewer, setShowViewer] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -355,6 +358,20 @@ function ArchiveCard({
             <Image className="w-12 h-12 text-bambu-dark-tertiary" />
           </div>
         )}
+        {/* Mobile menu button */}
+        {isMobile && (
+          <button
+            className="absolute top-2 right-10 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-colors"
+            onClick={(e) => {
+              e.stopPropagation();
+              const rect = e.currentTarget.getBoundingClientRect();
+              setContextMenu({ x: rect.left, y: rect.bottom + 4 });
+            }}
+            title="Menu"
+          >
+            <MoreVertical className="w-5 h-5 text-white" />
+          </button>
+        )}
         {/* Favorite star */}
         <button
           className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
@@ -1069,7 +1086,7 @@ export function ArchivesPage() {
 
   return (
     <div
-      className="p-8 relative min-h-full"
+      className="p-4 md:p-8 relative min-h-full"
       onDragOver={handleDragOver}
       onDragLeave={handleDragLeave}
       onDrop={handleDrop}
@@ -1175,20 +1192,23 @@ export function ArchivesPage() {
       {/* Filters */}
       <Card className="mb-6">
         <CardContent className="py-4">
-          <div className="flex gap-4 items-center flex-wrap">
-            <div className="flex-1 relative min-w-[200px]">
+          <div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
+            {/* Search - full width on mobile */}
+            <div className="w-full md:flex-1 relative md:min-w-[200px]">
               <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
               <input
                 ref={searchInputRef}
                 type="text"
-                placeholder="Search archives... (press /)"
-                className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                placeholder="Search archives..."
+                className="w-full pl-10 pr-4 py-3 md:py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                 value={search}
                 onChange={(e) => setSearch(e.target.value)}
               />
             </div>
-            <div className="flex items-center gap-2">
-              <Filter className="w-4 h-4 text-bambu-gray" />
+            {/* Filters - horizontal scroll on mobile */}
+            <div className="flex gap-2 md:gap-4 overflow-x-auto pb-1 md:pb-0 -mx-4 px-4 md:mx-0 md:px-0 md:flex-wrap scrollbar-hide">
+            <div className="flex items-center gap-2 flex-shrink-0">
+              <Filter className="w-4 h-4 text-bambu-gray hidden md:block" />
               <select
                 className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                 value={filterPrinter || ''}
@@ -1204,8 +1224,8 @@ export function ArchivesPage() {
                 ))}
               </select>
             </div>
-            <div className="flex items-center gap-2">
-              <Package className="w-4 h-4 text-bambu-gray" />
+            <div className="flex items-center gap-2 flex-shrink-0">
+              <Package className="w-4 h-4 text-bambu-gray hidden md:block" />
               <select
                 className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                 value={filterMaterial || ''}
@@ -1223,7 +1243,7 @@ export function ArchivesPage() {
             </div>
             <button
               onClick={() => setFilterFavorites(!filterFavorites)}
-              className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
+              className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${
                 filterFavorites
                   ? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
                   : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -1231,11 +1251,11 @@ export function ArchivesPage() {
               title={filterFavorites ? 'Show all' : 'Show favorites only'}
             >
               <Star className={`w-4 h-4 ${filterFavorites ? 'fill-yellow-400' : ''}`} />
-              <span className="text-sm">Favorites</span>
+              <span className="text-sm hidden md:inline">Favorites</span>
             </button>
             {uniqueTags.length > 0 && (
-              <div className="flex items-center gap-2">
-                <Tag className="w-4 h-4 text-bambu-gray" />
+              <div className="flex items-center gap-2 flex-shrink-0">
+                <Tag className="w-4 h-4 text-bambu-gray hidden md:block" />
                 <select
                   className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   value={filterTag || ''}
@@ -1250,8 +1270,8 @@ export function ArchivesPage() {
                 </select>
               </div>
             )}
-            <div className="flex items-center gap-2">
-              <ArrowUpDown className="w-4 h-4 text-bambu-gray" />
+            <div className="flex items-center gap-2 flex-shrink-0">
+              <ArrowUpDown className="w-4 h-4 text-bambu-gray hidden md:block" />
               <select
                 className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                 value={sortBy}
@@ -1265,7 +1285,7 @@ export function ArchivesPage() {
                 <option value="size-asc">Smallest first</option>
               </select>
             </div>
-            <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+            <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0">
               <button
                 className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
                 onClick={() => setViewMode('grid')}
@@ -1288,6 +1308,7 @@ export function ArchivesPage() {
                 <CalendarDays className="w-4 h-4" />
               </button>
             </div>
+            </div>
             {hasTopFilters && (
               <Button
                 variant="ghost"

+ 43 - 0
frontend/src/pages/ExternalLinkPage.tsx

@@ -0,0 +1,43 @@
+import { useParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { Loader2, AlertTriangle } from 'lucide-react';
+import { api } from '../api/client';
+import { useTheme } from '../contexts/ThemeContext';
+
+export function ExternalLinkPage() {
+  const { id } = useParams<{ id: string }>();
+  const { theme } = useTheme();
+
+  const { data: link, isLoading, error } = useQuery({
+    queryKey: ['external-link', id],
+    queryFn: () => api.getExternalLink(Number(id)),
+    enabled: !!id,
+  });
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center h-full">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  if (error || !link) {
+    return (
+      <div className="flex flex-col items-center justify-center h-full gap-4 text-bambu-gray">
+        <AlertTriangle className="w-12 h-12" />
+        <p>Link not found</p>
+      </div>
+    );
+  }
+
+  return (
+    <iframe
+      src={link.url}
+      className="h-full w-full border-0"
+      style={{ colorScheme: theme }}
+      title={link.name}
+      sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
+    />
+  );
+}

+ 2 - 2
frontend/src/pages/MaintenancePage.tsx

@@ -914,7 +914,7 @@ export function MaintenancePage() {
 
   if (isLoading) {
     return (
-      <div className="p-8 flex justify-center">
+      <div className="p-4 md:p-8 flex justify-center">
         <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
       </div>
     );
@@ -924,7 +924,7 @@ export function MaintenancePage() {
   const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
 
   return (
-    <div className="p-8">
+    <div className="p-4 md:p-8">
       {/* Header */}
       <div className="mb-6">
         <h1 className="text-2xl font-bold text-white">Maintenance</h1>

+ 41 - 30
frontend/src/pages/PrintersPage.tsx

@@ -1013,41 +1013,52 @@ function PrinterCard({
                           )}
                         </div>
 
-                        {/* Label and humidity/temp */}
+                        {/* Label and filament info */}
                         <div className="flex-1 min-w-0">
-                          <div className="flex items-center justify-between">
-                            <span className="text-xs text-bambu-gray font-medium">
-                              {getAmsLabel(ams.id, ams.tray.length)}
-                            </span>
-                            {(ams.humidity != null || ams.temp != null) && (
-                              <div className="flex items-center gap-2 text-xs">
-                                {ams.humidity != null && (
-                                  <HumidityIndicator
-                                    humidity={ams.humidity}
-                                    goodThreshold={amsThresholds?.humidityGood}
-                                    fairThreshold={amsThresholds?.humidityFair}
-                                  />
-                                )}
-                                {ams.temp != null && (
-                                  <TemperatureIndicator
-                                    temp={ams.temp}
-                                    goodThreshold={amsThresholds?.tempGood}
-                                    fairThreshold={amsThresholds?.tempFair}
-                                  />
+                          <span className="text-xs text-bambu-gray font-medium">
+                            {getAmsLabel(ams.id, ams.tray.length)}
+                          </span>
+                          {/* Filament types and fill levels */}
+                          <div className="mt-0.5 text-[10px] flex items-start">
+                            {ams.tray.map((tray, i) => (
+                              <div key={i} className="flex items-start">
+                                <div className="flex flex-col">
+                                  <span className="text-bambu-gray/70 truncate">
+                                    {tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'}
+                                  </span>
+                                  <span className="text-bambu-gray/50 truncate">
+                                    {tray.tray_type && tray.remain >= 0 ? `${tray.remain}%` : '—'}
+                                  </span>
+                                </div>
+                                {i < ams.tray.length - 1 && (
+                                  <span className="text-bambu-gray/50 mx-1 flex flex-col">
+                                    <span>·</span>
+                                    <span>·</span>
+                                  </span>
                                 )}
                               </div>
-                            )}
-                          </div>
-                          {/* Filament tooltips as small text */}
-                          <div className="flex gap-1 mt-0.5 text-[10px] text-bambu-gray/70 truncate">
-                            {ams.tray.map((tray, i) => (
-                              <span key={i} className="truncate">
-                                {tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'}
-                                {i < ams.tray.length - 1 && ' · '}
-                              </span>
                             ))}
                           </div>
                         </div>
+                        {/* Humidity/temp - vertically centered */}
+                        {(ams.humidity != null || ams.temp != null) && (
+                          <div className="flex items-center gap-2 text-xs flex-shrink-0">
+                            {ams.humidity != null && (
+                              <HumidityIndicator
+                                humidity={ams.humidity}
+                                goodThreshold={amsThresholds?.humidityGood}
+                                fairThreshold={amsThresholds?.humidityFair}
+                              />
+                            )}
+                            {ams.temp != null && (
+                              <TemperatureIndicator
+                                temp={ams.temp}
+                                goodThreshold={amsThresholds?.tempGood}
+                                fairThreshold={amsThresholds?.tempFair}
+                              />
+                            )}
+                          </div>
+                        )}
                       </div>
                     </div>
                   );
@@ -1819,7 +1830,7 @@ export function PrintersPage() {
   }, [sortBy, sortedPrinters]);
 
   return (
-    <div className="p-8">
+    <div className="p-4 md:p-8">
       <div className="flex items-center justify-between mb-6">
         <div>
           <h1 className="text-2xl font-bold text-white">Printers</h1>

+ 1 - 1
frontend/src/pages/ProfilesPage.tsx

@@ -2788,7 +2788,7 @@ export function ProfilesPage() {
 
   if (statusLoading) {
     return (
-      <div className="p-8 flex items-center justify-center min-h-[400px]">
+      <div className="p-4 md:p-8 flex items-center justify-center min-h-[400px]">
         <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
       </div>
     );

+ 3 - 3
frontend/src/pages/QueuePage.tsx

@@ -144,9 +144,9 @@ function SortableQueueItem({
           <div
             {...attributes}
             {...listeners}
-            className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors"
+            className="flex items-center justify-center w-10 h-10 md:w-8 md:h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation"
           >
-            <GripVertical className="w-4 h-4 text-bambu-gray" />
+            <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
           </div>
         ) : position !== undefined ? (
           <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
@@ -505,7 +505,7 @@ export function QueuePage() {
   };
 
   return (
-    <div className="p-8">
+    <div className="p-4 md:p-8">
       {/* Header */}
       <div className="flex items-center justify-between mb-8">
         <div>

+ 9 - 6
frontend/src/pages/SettingsPage.tsx

@@ -15,6 +15,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { BackupModal } from '../components/BackupModal';
 import { RestoreModal } from '../components/RestoreModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
+import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
@@ -304,14 +305,14 @@ export function SettingsPage() {
 
   if (isLoading || !localSettings) {
     return (
-      <div className="p-8 flex justify-center">
+      <div className="p-4 md:p-8 flex justify-center">
         <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
       </div>
     );
   }
 
   return (
-    <div className="p-8">
+    <div className="p-4 md:p-8">
       <div className="mb-8">
         <h1 className="text-2xl font-bold text-white">Settings</h1>
         <p className="text-bambu-gray">Configure Bambuddy</p>
@@ -365,9 +366,9 @@ export function SettingsPage() {
 
       {/* General Tab */}
       {activeTab === 'general' && (
-      <div className="flex gap-8">
+      <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
         {/* Left Column - General Settings */}
-        <div className="space-y-6 flex-1 max-w-xl">
+        <div className="space-y-6 flex-1 lg:max-w-xl">
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
@@ -629,7 +630,7 @@ export function SettingsPage() {
         </div>
 
         {/* Second Column - AMS & Spoolman */}
-        <div className="space-y-6 flex-1 max-w-md">
+        <div className="space-y-6 flex-1 lg:max-w-md">
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
@@ -734,10 +735,12 @@ export function SettingsPage() {
           </Card>
 
           <SpoolmanSettings />
+
+          <ExternalLinksSettings />
         </div>
 
         {/* Third Column - Updates */}
-        <div className="space-y-6 flex-1 max-w-sm">
+        <div className="space-y-6 flex-1 lg:max-w-sm">
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">Updates</h2>

+ 2 - 2
frontend/src/pages/StatsPage.tsx

@@ -338,7 +338,7 @@ export function StatsPage() {
 
   if (isLoading) {
     return (
-      <div className="p-8">
+      <div className="p-4 md:p-8">
         <div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
       </div>
     );
@@ -392,7 +392,7 @@ export function StatsPage() {
   ];
 
   return (
-    <div className="p-8">
+    <div className="p-4 md:p-8">
       <div className="mb-6">
         <h1 className="text-2xl font-bold text-white">Dashboard</h1>
         <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>

Plik diff jest za duży
+ 0 - 0
icons/27ca5e207eb045a7949048ab41fda285.svg


Plik diff jest za duży
+ 0 - 0
icons/57eeee2303f848be9d6159c1079f100d.svg


Plik diff jest za duży
+ 0 - 0
icons/df3231e72d3b4bc0a08c47e95599e64d.svg


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


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


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


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


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-CycmYzoY.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Ob3MFXab.css">
+    <script type="module" crossorigin src="/assets/index-2RQZZHMw.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BYZOEJWU.css">
   </head>
   <body>
     <div id="root"></div>

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