Просмотр исходного кода

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 месяцев назад
Родитель
Сommit
e1aa557862
41 измененных файлов с 1781 добавлено и 246 удалено
  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

+ 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_start=provider_data.on_print_start,
         on_print_complete=provider_data.on_print_complete,
         on_print_complete=provider_data.on_print_complete,
         on_print_failed=provider_data.on_print_failed,
         on_print_failed=provider_data.on_print_failed,
+        on_print_stopped=provider_data.on_print_stopped,
         on_print_progress=provider_data.on_print_progress,
         on_print_progress=provider_data.on_print_progress,
         # Printer status events
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,
         on_printer_error=provider_data.on_printer_error,
         on_filament_low=provider_data.on_filament_low,
         on_filament_low=provider_data.on_filament_low,
+        on_maintenance_due=provider_data.on_maintenance_due,
         # Quiet hours
         # Quiet hours
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_start=provider_data.quiet_hours_start,
         quiet_hours_start=provider_data.quiet_hours_start,
         quiet_hours_end=provider_data.quiet_hours_end,
         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 filter
         printer_id=provider_data.printer_id,
         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.filament import Filament
 from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
 from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
 from backend.app.models.archive import PrintArchive
 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.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
 from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
@@ -167,6 +168,7 @@ async def export_backup(
     include_notifications: bool = Query(True, description="Include notification providers"),
     include_notifications: bool = Query(True, description="Include notification providers"),
     include_templates: bool = Query(True, description="Include notification templates"),
     include_templates: bool = Query(True, description="Include notification templates"),
     include_smart_plugs: bool = Query(True, description="Include smart plugs"),
     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_printers: bool = Query(False, description="Include printers (without access codes)"),
     include_filaments: bool = Query(False, description="Include filament inventory"),
     include_filaments: bool = Query(False, description="Include filament inventory"),
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
     include_maintenance: bool = Query(False, description="Include maintenance types and records"),
@@ -258,6 +260,28 @@ async def export_backup(
             })
             })
         backup["included"].append("smart_plugs")
         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)
     # Printers (access codes only included if explicitly requested)
     if include_printers:
     if include_printers:
         result = await db.execute(select(Printer))
         result = await db.execute(select(Printer))
@@ -322,8 +346,19 @@ async def export_backup(
             })
             })
         backup["included"].append("maintenance_types")
         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
     # Print archives with file paths for ZIP
-    archive_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
     if include_archives:
     if include_archives:
         result = await db.execute(select(PrintArchive))
         result = await db.execute(select(PrintArchive))
         archives = result.scalars().all()
         archives = result.scalars().all()
@@ -366,25 +401,25 @@ async def export_backup(
                 file_path = base_dir / a.file_path
                 file_path = base_dir / a.file_path
                 if file_path.exists():
                 if file_path.exists():
                     archive_data["file_path"] = a.file_path
                     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:
             if a.thumbnail_path:
                 thumb_path = base_dir / a.thumbnail_path
                 thumb_path = base_dir / a.thumbnail_path
                 if thumb_path.exists():
                 if thumb_path.exists():
                     archive_data["thumbnail_path"] = a.thumbnail_path
                     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:
             if a.timelapse_path:
                 timelapse_path = base_dir / a.timelapse_path
                 timelapse_path = base_dir / a.timelapse_path
                 if timelapse_path.exists():
                 if timelapse_path.exists():
                     archive_data["timelapse_path"] = a.timelapse_path
                     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:
             if a.source_3mf_path:
                 source_path = base_dir / a.source_3mf_path
                 source_path = base_dir / a.source_3mf_path
                 if source_path.exists():
                 if source_path.exists():
                     archive_data["source_3mf_path"] = a.source_3mf_path
                     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
             # Include photos
             if a.photos:
             if a.photos:
@@ -392,21 +427,21 @@ async def export_backup(
                     photo_path = base_dir / "archive" / "photos" / photo
                     photo_path = base_dir / "archive" / "photos" / photo
                     if photo_path.exists():
                     if photo_path.exists():
                         zip_photo_path = f"archive/photos/{photo}"
                         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["archives"].append(archive_data)
         backup["included"].append("archives")
         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()
         zip_buffer = io.BytesIO()
         with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
         with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
             # Add backup.json
             # Add backup.json
             zf.writestr("backup.json", json.dumps(backup, indent=2))
             zf.writestr("backup.json", json.dumps(backup, indent=2))
 
 
-            # Add all archive files
+            # Add all backup files (icons, archives, etc.)
             added_files = set()
             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():
                 if zip_path not in added_files and local_path.exists():
                     try:
                     try:
                         zf.write(local_path, zip_path)
                         zf.write(local_path, zip_path)
@@ -481,6 +516,7 @@ async def import_backup(
         "notification_providers": 0,
         "notification_providers": 0,
         "notification_templates": 0,
         "notification_templates": 0,
         "smart_plugs": 0,
         "smart_plugs": 0,
+        "external_links": 0,
         "printers": 0,
         "printers": 0,
         "filaments": 0,
         "filaments": 0,
         "maintenance_types": 0,
         "maintenance_types": 0,
@@ -490,6 +526,7 @@ async def import_backup(
         "notification_providers": 0,
         "notification_providers": 0,
         "notification_templates": 0,
         "notification_templates": 0,
         "smart_plugs": 0,
         "smart_plugs": 0,
+        "external_links": 0,
         "printers": 0,
         "printers": 0,
         "filaments": 0,
         "filaments": 0,
         "maintenance_types": 0,
         "maintenance_types": 0,
@@ -498,24 +535,13 @@ async def import_backup(
     skipped_details = {
     skipped_details = {
         "notification_providers": [],
         "notification_providers": [],
         "smart_plugs": [],
         "smart_plugs": [],
+        "external_links": [],
         "printers": [],
         "printers": [],
         "filaments": [],
         "filaments": [],
         "maintenance_types": [],
         "maintenance_types": [],
         "archives": [],
         "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)
     # Restore settings (always overwrites)
     if "settings" in backup:
     if "settings" in backup:
         for key, value in backup["settings"].items():
         for key, value in backup["settings"].items():
@@ -664,20 +690,49 @@ async def import_backup(
                 db.add(plug)
                 db.add(plug)
                 restored["smart_plugs"] += 1
                 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:
     if "printers" in backup:
-        logger.info(f"Restore: Processing {len(backup['printers'])} printers from backup")
         for printer_data in backup["printers"]:
         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(
             result = await db.execute(
                 select(Printer).where(Printer.serial_number == printer_data["serial_number"])
                 select(Printer).where(Printer.serial_number == printer_data["serial_number"])
             )
             )
             existing = result.scalar_one_or_none()
             existing = result.scalar_one_or_none()
             if existing:
             if existing:
-                logger.info(f"Restore: Printer already exists (id={existing.id}, is_active={existing.is_active})")
                 if overwrite:
                 if overwrite:
                     existing.name = printer_data["name"]
                     existing.name = printer_data["name"]
                     existing.ip_address = printer_data["ip_address"]
                     existing.ip_address = printer_data["ip_address"]
@@ -695,14 +750,11 @@ async def import_backup(
                         if isinstance(is_active_val, str):
                         if isinstance(is_active_val, str):
                             is_active_val = is_active_val.lower() == "true"
                             is_active_val = is_active_val.lower() == "true"
                         existing.is_active = is_active_val
                         existing.is_active = is_active_val
-                        logger.info(f"Restore: Updated access_code and is_active={is_active_val} from backup")
 
 
                     restored["printers"] += 1
                     restored["printers"] += 1
-                    logger.info(f"Restore: Updated existing printer (overwrite=True)")
                 else:
                 else:
                     skipped["printers"] += 1
                     skipped["printers"] += 1
                     skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
                     skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
-                    logger.info(f"Restore: Skipped existing printer (overwrite=False)")
             else:
             else:
                 # Use access code from backup if provided, otherwise require manual setup
                 # Use access code from backup if provided, otherwise require manual setup
                 access_code = printer_data.get("access_code")
                 access_code = printer_data.get("access_code")
@@ -712,16 +764,6 @@ async def import_backup(
                 if isinstance(is_active_from_backup, str):
                 if isinstance(is_active_from_backup, str):
                     is_active_from_backup = is_active_from_backup.lower() == "true"
                     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(
                 printer = Printer(
                     name=printer_data["name"],
                     name=printer_data["name"],
                     serial_number=printer_data["serial_number"],
                     serial_number=printer_data["serial_number"],
@@ -867,9 +909,6 @@ async def import_backup(
 
 
     await db.commit()
     await db.commit()
 
 
-    import logging
-    logger = logging.getLogger(__name__)
-
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
     # 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
     # This ensures connections are re-established after restore, even if printers were skipped
     if "printers" in backup:
     if "printers" in backup:
@@ -878,15 +917,12 @@ async def import_backup(
             select(Printer).where(Printer.is_active == True)
             select(Printer).where(Printer.is_active == True)
         )
         )
         active_printers = result.scalars().all()
         active_printers = result.scalars().all()
-        logger.info(f"Restore: Found {len(active_printers)} active printers to reconnect")
         for printer in active_printers:
         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
             # This will disconnect existing connection (if any) and reconnect
             try:
             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 were restored, check if Spoolman needs to be reconnected
     if "settings" in backup:
     if "settings" in backup:

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

@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
 import logging
 import logging
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.5b"
+APP_VERSION = "0.1.5b4"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # Base directory for path calculations
 # 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():
 async def init_db():
     # Import models to register them with SQLAlchemy
     # 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:
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
         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 backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
 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.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
 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(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.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(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)
 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 { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { CameraPage } from './pages/CameraPage';
 import { CameraPage } from './pages/CameraPage';
+import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -46,6 +47,7 @@ function App() {
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="settings" element={<SettingsPage />} />
+                  <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
                 </Route>
               </Routes>
               </Routes>
             </BrowserRouter>
             </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
 // API functions
 export const api = {
 export const api = {
   // Printers
   // Printers
@@ -1506,4 +1530,41 @@ export const api = {
     `${API_BASE}/printers/${printerId}/camera/snapshot`,
     `${API_BASE}/printers/${printerId}/camera/snapshot`,
   testCameraConnection: (printerId: number) =>
   testCameraConnection: (printerId: number) =>
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
     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 = {
   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 (
   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"
             className="cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
             title="Drag to reorder"
             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>
           </button>
           <h3 className="text-sm font-medium text-white">{title}</h3>
           <h3 className="text-sm font-medium text-white">{title}</h3>
         </div>
         </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 { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 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 { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { getIconByName } from './IconPicker';
+import { useIsMobile } from '../hooks/useIsMobile';
 
 
 interface NavItem {
 interface NavItem {
   id: string;
   id: string;
@@ -24,36 +26,27 @@ export const defaultNavItems: NavItem[] = [
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
   { 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');
   const stored = localStorage.getItem('sidebarOrder');
   if (stored) {
   if (stored) {
     try {
     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 {
     } 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
 // Get default view from localStorage
@@ -71,14 +64,16 @@ export function Layout() {
   const location = useLocation();
   const location = useLocation();
   const { theme, toggleTheme } = useTheme();
   const { theme, toggleTheme } = useTheme();
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const isMobile = useIsMobile();
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
     const stored = localStorage.getItem('sidebarExpanded');
     const stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
     return stored !== 'false';
   });
   });
+  const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
   const [showShortcuts, setShowShortcuts] = 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 hasRedirected = useRef(false);
   const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
   const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
     sessionStorage.getItem('dismissedUpdateVersion')
     sessionStorage.getItem('dismissedUpdateVersion')
@@ -105,6 +100,101 @@ export function Layout() {
     refetchInterval: 60 * 60 * 1000, // Check every hour
     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
   // Show update banner if update available and not dismissed for this version
   const showUpdateBanner = updateCheck?.update_available &&
   const showUpdateBanner = updateCheck?.update_available &&
     updateCheck.latest_version &&
     updateCheck.latest_version &&
@@ -132,45 +222,12 @@ export function Layout() {
     localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
     localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
   }, [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
   // Global keyboard shortcuts for navigation
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -180,13 +237,17 @@ export function Layout() {
       return;
       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) {
     if (!e.metaKey && !e.ctrlKey && !e.altKey) {
       const keyNum = parseInt(e.key);
       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) {
       switch (e.key) {
@@ -199,7 +260,7 @@ export function Layout() {
           break;
           break;
       }
       }
     }
     }
-  }, [navigate, navItems]);
+  }, [navigate, orderedSidebarIds, navItemsMap]);
 
 
   useEffect(() => {
   useEffect(() => {
     document.addEventListener('keydown', handleKeyDown);
     document.addEventListener('keydown', handleKeyDown);
@@ -208,77 +269,170 @@ export function Layout() {
 
 
   return (
   return (
     <div className="flex min-h-screen">
     <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
       <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 */}
         {/* 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
           <img
             src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
             src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
             alt="Bambuddy"
             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>
         </div>
 
 
         {/* Navigation */}
         {/* Navigation */}
         <nav className="flex-1 p-2">
         <nav className="flex-1 p-2">
           <ul className="space-y-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>
           </ul>
         </nav>
         </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 */}
         {/* Footer */}
         <div className="p-2 border-t border-bambu-dark-tertiary">
         <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 justify-between px-2">
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
                 <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
@@ -359,7 +513,9 @@ export function Layout() {
       </aside>
       </aside>
 
 
       {/* Main content */}
       {/* 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 */}
         {/* Persistent update banner */}
         {showUpdateBanner && (
         {showUpdateBanner && (
           <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
           <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>
       </main>
 
 
       {/* Keyboard Shortcuts Modal */}
       {/* 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>
     </div>
   );
   );
 }
 }

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

@@ -20,7 +20,7 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
       aria-checked={checked}
       aria-checked={checked}
       disabled={disabled}
       disabled={disabled}
       onClick={handleClick}
       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
         disabled
           ? 'bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50'
           ? 'bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50'
           : checked
           : checked
@@ -29,7 +29,7 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
       }`}
       }`}
     >
     >
       <span
       <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'
           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
     #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,
   Camera,
   FileText,
   FileText,
   FileCode,
   FileCode,
+  MoreVertical,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive } from '../api/client';
 import type { Archive } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -93,6 +95,7 @@ function ArchiveCard({
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const isMobile = useIsMobile();
   const [showViewer, setShowViewer] = useState(false);
   const [showViewer, setShowViewer] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -355,6 +358,20 @@ function ArchiveCard({
             <Image className="w-12 h-12 text-bambu-dark-tertiary" />
             <Image className="w-12 h-12 text-bambu-dark-tertiary" />
           </div>
           </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 */}
         {/* Favorite star */}
         <button
         <button
           className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
           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 (
   return (
     <div
     <div
-      className="p-8 relative min-h-full"
+      className="p-4 md:p-8 relative min-h-full"
       onDragOver={handleDragOver}
       onDragOver={handleDragOver}
       onDragLeave={handleDragLeave}
       onDragLeave={handleDragLeave}
       onDrop={handleDrop}
       onDrop={handleDrop}
@@ -1175,20 +1192,23 @@ export function ArchivesPage() {
       {/* Filters */}
       {/* Filters */}
       <Card className="mb-6">
       <Card className="mb-6">
         <CardContent className="py-4">
         <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" />
               <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
               <input
               <input
                 ref={searchInputRef}
                 ref={searchInputRef}
                 type="text"
                 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}
                 value={search}
                 onChange={(e) => setSearch(e.target.value)}
                 onChange={(e) => setSearch(e.target.value)}
               />
               />
             </div>
             </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
               <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"
                 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 || ''}
                 value={filterPrinter || ''}
@@ -1204,8 +1224,8 @@ export function ArchivesPage() {
                 ))}
                 ))}
               </select>
               </select>
             </div>
             </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
               <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"
                 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 || ''}
                 value={filterMaterial || ''}
@@ -1223,7 +1243,7 @@ export function ArchivesPage() {
             </div>
             </div>
             <button
             <button
               onClick={() => setFilterFavorites(!filterFavorites)}
               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
                 filterFavorites
                   ? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
                   ? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
                   : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
                   : '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'}
               title={filterFavorites ? 'Show all' : 'Show favorites only'}
             >
             >
               <Star className={`w-4 h-4 ${filterFavorites ? 'fill-yellow-400' : ''}`} />
               <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>
             </button>
             {uniqueTags.length > 0 && (
             {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
                 <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"
                   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 || ''}
                   value={filterTag || ''}
@@ -1250,8 +1270,8 @@ export function ArchivesPage() {
                 </select>
                 </select>
               </div>
               </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
               <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"
                 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}
                 value={sortBy}
@@ -1265,7 +1285,7 @@ export function ArchivesPage() {
                 <option value="size-asc">Smallest first</option>
                 <option value="size-asc">Smallest first</option>
               </select>
               </select>
             </div>
             </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
               <button
                 className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
                 className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
                 onClick={() => setViewMode('grid')}
                 onClick={() => setViewMode('grid')}
@@ -1288,6 +1308,7 @@ export function ArchivesPage() {
                 <CalendarDays className="w-4 h-4" />
                 <CalendarDays className="w-4 h-4" />
               </button>
               </button>
             </div>
             </div>
+            </div>
             {hasTopFilters && (
             {hasTopFilters && (
               <Button
               <Button
                 variant="ghost"
                 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) {
   if (isLoading) {
     return (
     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" />
         <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
       </div>
       </div>
     );
     );
@@ -924,7 +924,7 @@ export function MaintenancePage() {
   const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
   const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
 
 
   return (
   return (
-    <div className="p-8">
+    <div className="p-4 md:p-8">
       {/* Header */}
       {/* Header */}
       <div className="mb-6">
       <div className="mb-6">
         <h1 className="text-2xl font-bold text-white">Maintenance</h1>
         <h1 className="text-2xl font-bold text-white">Maintenance</h1>

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

@@ -1013,41 +1013,52 @@ function PrinterCard({
                           )}
                           )}
                         </div>
                         </div>
 
 
-                        {/* Label and humidity/temp */}
+                        {/* Label and filament info */}
                         <div className="flex-1 min-w-0">
                         <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>
-                            )}
-                          </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>
                         </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>
                     </div>
                     </div>
                   );
                   );
@@ -1819,7 +1830,7 @@ export function PrintersPage() {
   }, [sortBy, sortedPrinters]);
   }, [sortBy, sortedPrinters]);
 
 
   return (
   return (
-    <div className="p-8">
+    <div className="p-4 md:p-8">
       <div className="flex items-center justify-between mb-6">
       <div className="flex items-center justify-between mb-6">
         <div>
         <div>
           <h1 className="text-2xl font-bold text-white">Printers</h1>
           <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) {
   if (statusLoading) {
     return (
     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" />
         <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
       </div>
       </div>
     );
     );

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

@@ -144,9 +144,9 @@ function SortableQueueItem({
           <div
           <div
             {...attributes}
             {...attributes}
             {...listeners}
             {...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>
           </div>
         ) : position !== undefined ? (
         ) : 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">
           <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 (
   return (
-    <div className="p-8">
+    <div className="p-4 md:p-8">
       {/* Header */}
       {/* Header */}
       <div className="flex items-center justify-between mb-8">
       <div className="flex items-center justify-between mb-8">
         <div>
         <div>

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

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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
icons/27ca5e207eb045a7949048ab41fda285.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
icons/57eeee2303f848be9d6159c1079f100d.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
icons/df3231e72d3b4bc0a08c47e95599e64d.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-2RQZZHMw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BYZOEJWU.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CycmYzoY.js


Разница между файлами не показана из-за своего большого размера
+ 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="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.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" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов