diff --git a/._.DS_Store b/._.DS_Store new file mode 100644 index 000000000..28c42fb20 Binary files /dev/null and b/._.DS_Store differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..ad60d0775 --- /dev/null +++ b/.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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b38064d73 --- /dev/null +++ b/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"] diff --git a/backend/app/api/routes/external_links.py b/backend/app/api/routes/external_links.py new file mode 100644 index 000000000..7992dd4fe --- /dev/null +++ b/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) diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index da470799d..316765935 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -94,15 +94,20 @@ async def create_notification_provider( on_print_start=provider_data.on_print_start, on_print_complete=provider_data.on_print_complete, on_print_failed=provider_data.on_print_failed, + on_print_stopped=provider_data.on_print_stopped, on_print_progress=provider_data.on_print_progress, # Printer status events on_printer_offline=provider_data.on_printer_offline, on_printer_error=provider_data.on_printer_error, on_filament_low=provider_data.on_filament_low, + on_maintenance_due=provider_data.on_maintenance_due, # Quiet hours quiet_hours_enabled=provider_data.quiet_hours_enabled, quiet_hours_start=provider_data.quiet_hours_start, quiet_hours_end=provider_data.quiet_hours_end, + # Daily digest + daily_digest_enabled=provider_data.daily_digest_enabled, + daily_digest_time=provider_data.daily_digest_time, # Printer filter printer_id=provider_data.printer_id, ) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index cad82ef49..df1fe0eb5 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -20,6 +20,7 @@ from backend.app.models.printer import Printer from backend.app.models.filament import Filament from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory from backend.app.models.archive import PrintArchive +from backend.app.models.external_link import ExternalLink from backend.app.schemas.settings import AppSettings, AppSettingsUpdate from backend.app.services.printer_manager import printer_manager from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client @@ -167,6 +168,7 @@ async def export_backup( include_notifications: bool = Query(True, description="Include notification providers"), include_templates: bool = Query(True, description="Include notification templates"), include_smart_plugs: bool = Query(True, description="Include smart plugs"), + include_external_links: bool = Query(True, description="Include external sidebar links"), include_printers: bool = Query(False, description="Include printers (without access codes)"), include_filaments: bool = Query(False, description="Include filament inventory"), include_maintenance: bool = Query(False, description="Include maintenance types and records"), @@ -258,6 +260,28 @@ async def export_backup( }) backup["included"].append("smart_plugs") + # External links + if include_external_links: + result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order)) + links = result.scalars().all() + backup["external_links"] = [] + icons_dir = app_settings.base_dir / "icons" + for link in links: + link_data = { + "name": link.name, + "url": link.url, + "icon": link.icon, + "sort_order": link.sort_order, + } + # Include custom icon file path if exists + if link.custom_icon: + link_data["custom_icon"] = link.custom_icon + icon_path = icons_dir / link.custom_icon + if icon_path.exists(): + link_data["custom_icon_path"] = f"icons/{link.custom_icon}" + backup["external_links"].append(link_data) + backup["included"].append("external_links") + # Printers (access codes only included if explicitly requested) if include_printers: result = await db.execute(select(Printer)) @@ -322,8 +346,19 @@ async def export_backup( }) backup["included"].append("maintenance_types") + # Collect files for ZIP (icons + archives) + backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path) + + # Add external link icon files + if include_external_links and "external_links" in backup: + icons_dir = app_settings.base_dir / "icons" + for link_data in backup["external_links"]: + if "custom_icon_path" in link_data: + icon_path = icons_dir / link_data["custom_icon"] + if icon_path.exists(): + backup_files.append((link_data["custom_icon_path"], icon_path)) + # Print archives with file paths for ZIP - archive_files: list[tuple[str, Path]] = [] # (zip_path, local_path) if include_archives: result = await db.execute(select(PrintArchive)) archives = result.scalars().all() @@ -366,25 +401,25 @@ async def export_backup( file_path = base_dir / a.file_path if file_path.exists(): archive_data["file_path"] = a.file_path - archive_files.append((a.file_path, file_path)) + backup_files.append((a.file_path, file_path)) if a.thumbnail_path: thumb_path = base_dir / a.thumbnail_path if thumb_path.exists(): archive_data["thumbnail_path"] = a.thumbnail_path - archive_files.append((a.thumbnail_path, thumb_path)) + backup_files.append((a.thumbnail_path, thumb_path)) if a.timelapse_path: timelapse_path = base_dir / a.timelapse_path if timelapse_path.exists(): archive_data["timelapse_path"] = a.timelapse_path - archive_files.append((a.timelapse_path, timelapse_path)) + backup_files.append((a.timelapse_path, timelapse_path)) if a.source_3mf_path: source_path = base_dir / a.source_3mf_path if source_path.exists(): archive_data["source_3mf_path"] = a.source_3mf_path - archive_files.append((a.source_3mf_path, source_path)) + backup_files.append((a.source_3mf_path, source_path)) # Include photos if a.photos: @@ -392,21 +427,21 @@ async def export_backup( photo_path = base_dir / "archive" / "photos" / photo if photo_path.exists(): zip_photo_path = f"archive/photos/{photo}" - archive_files.append((zip_photo_path, photo_path)) + backup_files.append((zip_photo_path, photo_path)) backup["archives"].append(archive_data) backup["included"].append("archives") - # If archives included, create ZIP file with all files - if include_archives and archive_files: + # If there are files to include (icons or archives), create ZIP file + if backup_files: zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: # Add backup.json zf.writestr("backup.json", json.dumps(backup, indent=2)) - # Add all archive files + # Add all backup files (icons, archives, etc.) added_files = set() - for zip_path, local_path in archive_files: + for zip_path, local_path in backup_files: if zip_path not in added_files and local_path.exists(): try: zf.write(local_path, zip_path) @@ -481,6 +516,7 @@ async def import_backup( "notification_providers": 0, "notification_templates": 0, "smart_plugs": 0, + "external_links": 0, "printers": 0, "filaments": 0, "maintenance_types": 0, @@ -490,6 +526,7 @@ async def import_backup( "notification_providers": 0, "notification_templates": 0, "smart_plugs": 0, + "external_links": 0, "printers": 0, "filaments": 0, "maintenance_types": 0, @@ -498,24 +535,13 @@ async def import_backup( skipped_details = { "notification_providers": [], "smart_plugs": [], + "external_links": [], "printers": [], "filaments": [], "maintenance_types": [], "archives": [], } - # Log what's in the backup - import logging - restore_logger = logging.getLogger(__name__) - restore_logger.info(f"Restore: Backup version={backup.get('version')}, included={backup.get('included', [])}") - restore_logger.info(f"Restore: overwrite={overwrite}") - if "printers" in backup: - restore_logger.info(f"Restore: Backup contains {len(backup['printers'])} printers") - for p in backup["printers"]: - restore_logger.info(f" - {p.get('name')}: access_code={'YES' if p.get('access_code') else 'NO'}, is_active={p.get('is_active')}") - else: - restore_logger.info("Restore: Backup does NOT contain printers") - # Restore settings (always overwrites) if "settings" in backup: for key, value in backup["settings"].items(): @@ -664,20 +690,49 @@ async def import_backup( db.add(plug) restored["smart_plugs"] += 1 - # Restore printers (skip or overwrite duplicates by serial_number) - import logging - logger = logging.getLogger(__name__) + # Restore external links (skip or overwrite duplicates by name+url) + if "external_links" in backup: + icons_dir = base_dir / "icons" + icons_dir.mkdir(parents=True, exist_ok=True) + for link_data in backup["external_links"]: + result = await db.execute( + select(ExternalLink).where( + ExternalLink.name == link_data["name"], + ExternalLink.url == link_data["url"] + ) + ) + existing = result.scalar_one_or_none() + if existing: + if overwrite: + existing.icon = link_data.get("icon", "link") + existing.sort_order = link_data.get("sort_order", 0) + # Handle custom icon + if link_data.get("custom_icon"): + existing.custom_icon = link_data["custom_icon"] + restored["external_links"] += 1 + else: + skipped["external_links"] += 1 + skipped_details["external_links"].append(link_data["name"]) + else: + link = ExternalLink( + name=link_data["name"], + url=link_data["url"], + icon=link_data.get("icon", "link"), + custom_icon=link_data.get("custom_icon"), + sort_order=link_data.get("sort_order", 0), + ) + db.add(link) + restored["external_links"] += 1 + + # Restore printers (skip or overwrite duplicates by serial_number) if "printers" in backup: - logger.info(f"Restore: Processing {len(backup['printers'])} printers from backup") for printer_data in backup["printers"]: - logger.info(f"Restore: Processing printer {printer_data.get('name')} (serial: {printer_data.get('serial_number')})") result = await db.execute( select(Printer).where(Printer.serial_number == printer_data["serial_number"]) ) existing = result.scalar_one_or_none() if existing: - logger.info(f"Restore: Printer already exists (id={existing.id}, is_active={existing.is_active})") if overwrite: existing.name = printer_data["name"] existing.ip_address = printer_data["ip_address"] @@ -695,14 +750,11 @@ async def import_backup( if isinstance(is_active_val, str): is_active_val = is_active_val.lower() == "true" existing.is_active = is_active_val - logger.info(f"Restore: Updated access_code and is_active={is_active_val} from backup") restored["printers"] += 1 - logger.info(f"Restore: Updated existing printer (overwrite=True)") else: skipped["printers"] += 1 skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})") - logger.info(f"Restore: Skipped existing printer (overwrite=False)") else: # Use access code from backup if provided, otherwise require manual setup access_code = printer_data.get("access_code") @@ -712,16 +764,6 @@ async def import_backup( if isinstance(is_active_from_backup, str): is_active_from_backup = is_active_from_backup.lower() == "true" - import logging - logger = logging.getLogger(__name__) - logger.info(f"Restore: Creating printer {printer_data['name']}") - logger.info(f" - access_code in backup: {'YES' if 'access_code' in printer_data else 'NO'}") - logger.info(f" - access_code value: {access_code[:4] + '...' if access_code and len(access_code) > 4 else access_code}") - logger.info(f" - has_access_code (valid): {has_access_code}") - logger.info(f" - is_active in backup: {printer_data.get('is_active')} (type: {type(printer_data.get('is_active')).__name__})") - logger.info(f" - is_active_from_backup (converted): {is_active_from_backup}") - logger.info(f" - final is_active: {is_active_from_backup if has_access_code else False}") - printer = Printer( name=printer_data["name"], serial_number=printer_data["serial_number"], @@ -867,9 +909,6 @@ async def import_backup( await db.commit() - import logging - logger = logging.getLogger(__name__) - # If printers were in the backup (restored, updated, or skipped), reconnect all active printers # This ensures connections are re-established after restore, even if printers were skipped if "printers" in backup: @@ -878,15 +917,12 @@ async def import_backup( select(Printer).where(Printer.is_active == True) ) active_printers = result.scalars().all() - logger.info(f"Restore: Found {len(active_printers)} active printers to reconnect") for printer in active_printers: - logger.info(f"Restore: Reconnecting printer {printer.name} (id={printer.id}, ip={printer.ip_address}, access_code={'SET' if printer.access_code and printer.access_code != 'CHANGE_ME' else 'NOT SET'})") # This will disconnect existing connection (if any) and reconnect try: - connected = await printer_manager.connect_printer(printer) - logger.info(f"Restore: Printer {printer.name} connection result: {connected}") - except Exception as e: - logger.error(f"Restore: Failed to connect printer {printer.name}: {e}") + await printer_manager.connect_printer(printer) + except Exception: + pass # Connection failed, but don't fail the restore # If settings were restored, check if Spoolman needs to be reconnected if "settings" in backup: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2fc9f9578..620d6af6f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings import logging # Application version - single source of truth -APP_VERSION = "0.1.5b" +APP_VERSION = "0.1.5b4" GITHUB_REPO = "maziggy/bambuddy" # Base directory for path calculations diff --git a/backend/app/core/database.py b/backend/app/core/database.py index b43e29690..3e9d97b95 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -34,7 +34,7 @@ async def get_db() -> AsyncSession: async def init_db(): # Import models to register them with SQLAlchemy - from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template # noqa: F401 + from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/main.py b/backend/app/main.py index fcedabc79..b6bb2087b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,7 +54,7 @@ from fastapi.responses import FileResponse from backend.app.core.database import init_db, async_session from sqlalchemy import select, or_ from backend.app.core.websocket import ws_manager -from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera +from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links from backend.app.api.routes import settings as settings_routes from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import ( @@ -1031,6 +1031,7 @@ app.include_router(spoolman.router, prefix=app_settings.api_prefix) app.include_router(updates.router, prefix=app_settings.api_prefix) app.include_router(maintenance.router, prefix=app_settings.api_prefix) app.include_router(camera.router, prefix=app_settings.api_prefix) +app.include_router(external_links.router, prefix=app_settings.api_prefix) app.include_router(websocket.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/external_link.py b/backend/app/models/external_link.py new file mode 100644 index 000000000..faab0852a --- /dev/null +++ b/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() + ) diff --git a/backend/app/schemas/external_link.py b/backend/app/schemas/external_link.py new file mode 100644 index 000000000..75d6858b6 --- /dev/null +++ b/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") diff --git a/build_docker.sh b/build_docker.sh new file mode 100755 index 000000000..145e869e0 --- /dev/null +++ b/build_docker.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +sudo DOCKER_BUILDKIT=0 docker compose build diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..fd01379d2 --- /dev/null +++ b/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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d7863066..c93a4a64c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { SettingsPage } from './pages/SettingsPage'; import { ProfilesPage } from './pages/ProfilesPage'; import { MaintenancePage } from './pages/MaintenancePage'; import { CameraPage } from './pages/CameraPage'; +import { ExternalLinkPage } from './pages/ExternalLinkPage'; import { useWebSocket } from './hooks/useWebSocket'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; @@ -46,6 +47,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e59f81b3d..6ccd9d933 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -881,6 +881,30 @@ export interface MaintenanceSummary { }>; } +// External Links (sidebar) +export interface ExternalLink { + id: number; + name: string; + url: string; + icon: string; + custom_icon: string | null; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface ExternalLinkCreate { + name: string; + url: string; + icon: string; +} + +export interface ExternalLinkUpdate { + name?: string; + url?: string; + icon?: string; +} + // API functions export const api = { // Printers @@ -1506,4 +1530,41 @@ export const api = { `${API_BASE}/printers/${printerId}/camera/snapshot`, testCameraConnection: (printerId: number) => request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`), + + // External Links + getExternalLinks: () => request('/external-links/'), + getExternalLink: (id: number) => request(`/external-links/${id}`), + createExternalLink: (data: ExternalLinkCreate) => + request('/external-links/', { + method: 'POST', + body: JSON.stringify(data), + }), + updateExternalLink: (id: number, data: ExternalLinkUpdate) => + request(`/external-links/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteExternalLink: (id: number) => + request<{ message: string }>(`/external-links/${id}`, { method: 'DELETE' }), + reorderExternalLinks: (ids: number[]) => + request('/external-links/reorder', { + method: 'PUT', + body: JSON.stringify({ ids }), + }), + uploadExternalLinkIcon: async (id: number, file: File): Promise => { + 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(`/external-links/${id}/icon`, { method: 'DELETE' }), + getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`, }; diff --git a/frontend/src/components/AddExternalLinkModal.tsx b/frontend/src/components/AddExternalLinkModal.tsx new file mode 100644 index 000000000..086508497 --- /dev/null +++ b/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(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( + link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null + ); + const [pendingIconFile, setPendingIconFile] = useState(null); + const [error, setError] = useState(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) => { + 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ {useCustomIcon && customIconPreview ? ( + + ) : ( + + )} +
+

+ {isEditing ? 'Edit Link' : 'Add External Link'} +

+
+ +
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Name */} +
+ + 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" + /> +
+ + {/* URL */} +
+ + 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" + /> +
+ + {/* Icon Section */} +
+ + + {/* Custom Icon Upload */} +
+
+ Custom Icon + + {useCustomIcon && customIconPreview ? ( +
+ Custom icon + +
+ ) : ( + + )} +
+

+ PNG, JPG, GIF, SVG, WebP, or ICO. Max 1MB. +

+
+ + {/* Preset Icon Picker */} + {!useCustomIcon && ( +
+ Or choose a preset icon + +
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index fe6f106e3..e4bcc04fd 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -26,9 +26,9 @@ export function Button({ }; const sizes = { - sm: 'px-3 py-1.5 text-sm gap-1.5', - md: 'px-4 py-2 text-sm gap-2', - lg: 'px-6 py-3 text-base gap-2', + sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0', + md: 'px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0', + lg: 'px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0', }; return ( diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 3cf38a658..28661cedd 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -93,7 +93,7 @@ function SortableWidget({ className="cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors" title="Drag to reorder" > - +

{title}

diff --git a/frontend/src/components/ExternalLinksSettings.tsx b/frontend/src/components/ExternalLinksSettings.tsx new file mode 100644 index 000000000..d794480b6 --- /dev/null +++ b/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(null); + const [deletingLink, setDeletingLink] = useState(null); + const [draggedId, setDraggedId] = useState(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 ( + <> + + +
+
+ +

Sidebar Links

+
+ +
+
+ +

+ Add external links to the sidebar navigation. Drag to reorder. +

+ + {isLoading ? ( +
+ +
+ ) : links && links.length > 0 ? ( +
+ {links.map((link) => { + const Icon = getIconByName(link.icon); + return ( +
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' : '' + }`} + > + +
+ +
+
+
+ {link.name} + +
+ {link.url} +
+
+ + +
+
+ ); + })} +
+ ) : ( +
+ +

No external links configured

+

Click "Add Link" to add one

+
+ )} +
+
+ + {/* Add/Edit Modal */} + {(showAddModal || editingLink) && ( + { + setShowAddModal(false); + setEditingLink(null); + }} + /> + )} + + {/* Delete Confirmation Modal */} + {deletingLink && ( + setDeletingLink(null)} + /> + )} + + ); +} diff --git a/frontend/src/components/IconPicker.tsx b/frontend/src/components/IconPicker.tsx new file mode 100644 index 000000000..001006332 --- /dev/null +++ b/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 ( +
+ + + {isOpen && ( + <> + {/* Backdrop */} +
setIsOpen(false)} + /> + + {/* Dropdown */} +
+
+ {AVAILABLE_ICONS.map(({ name, icon: Icon }) => ( + + ))} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 81865c7c4..ac6113548 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,11 +1,13 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, type LucideIcon } from 'lucide-react'; +import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, Menu, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; import { useQuery } from '@tanstack/react-query'; import { api } from '../api/client'; +import { getIconByName } from './IconPicker'; +import { useIsMobile } from '../hooks/useIsMobile'; interface NavItem { id: string; @@ -24,36 +26,27 @@ export const defaultNavItems: NavItem[] = [ { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, ]; -// Get ordered nav items from localStorage -function getOrderedNavItems(): NavItem[] { +// Get unified sidebar order from localStorage +function getSidebarOrder(): string[] { const stored = localStorage.getItem('sidebarOrder'); if (stored) { try { - const order: string[] = JSON.parse(stored); - const itemMap = new Map(defaultNavItems.map(item => [item.id, item])); - const ordered: NavItem[] = []; - for (const id of order) { - const item = itemMap.get(id); - if (item) { - ordered.push(item); - itemMap.delete(id); - } - } - // Add any new items that weren't in the stored order - for (const item of itemMap.values()) { - ordered.push(item); - } - return ordered; + return JSON.parse(stored); } catch { - return defaultNavItems; + return defaultNavItems.map(i => i.id); } } - return defaultNavItems; + return defaultNavItems.map(i => i.id); } -// Save nav item order to localStorage -function saveNavOrder(items: NavItem[]) { - localStorage.setItem('sidebarOrder', JSON.stringify(items.map(i => i.id))); +// Save unified sidebar order to localStorage +function saveSidebarOrder(order: string[]) { + localStorage.setItem('sidebarOrder', JSON.stringify(order)); +} + +// Check if an ID is an external link +function isExternalLinkId(id: string): boolean { + return id.startsWith('ext-'); } // Get default view from localStorage @@ -71,14 +64,16 @@ export function Layout() { const location = useLocation(); const { theme, toggleTheme } = useTheme(); const { t } = useTranslation(); + const isMobile = useIsMobile(); const [sidebarExpanded, setSidebarExpanded] = useState(() => { const stored = localStorage.getItem('sidebarExpanded'); return stored !== 'false'; }); + const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); - const [navItems, setNavItems] = useState(getOrderedNavItems); - const [draggedIndex, setDraggedIndex] = useState(null); - const [dragOverIndex, setDragOverIndex] = useState(null); + const [sidebarOrder, setSidebarOrder] = useState(getSidebarOrder); + const [draggedId, setDraggedId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); const hasRedirected = useRef(false); const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState(() => sessionStorage.getItem('dismissedUpdateVersion') @@ -105,6 +100,101 @@ export function Layout() { refetchInterval: 60 * 60 * 1000, // Check every hour }); + // Fetch external links for sidebar + const { data: externalLinks } = useQuery({ + queryKey: ['external-links'], + queryFn: api.getExternalLinks, + }); + + // Build the unified sidebar items list + const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item])); + const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])); + + // Compute the ordered sidebar: include stored order + any new items + const orderedSidebarIds = (() => { + const result: string[] = []; + const seen = new Set(); + + // Add items in stored order + for (const id of sidebarOrder) { + if (navItemsMap.has(id) || extLinksMap.has(id)) { + result.push(id); + seen.add(id); + } + } + + // Add any new internal nav items not in stored order + for (const item of defaultNavItems) { + if (!seen.has(item.id)) { + result.push(item.id); + seen.add(item.id); + } + } + + // Add any new external links not in stored order + for (const link of externalLinks || []) { + const extId = `ext-${link.id}`; + if (!seen.has(extId)) { + result.push(extId); + seen.add(extId); + } + } + + return result; + })(); + + // Unified drag handlers + const handleDragStart = (e: React.DragEvent, id: string) => { + setDraggedId(id); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', id); + }; + + const handleDragOver = (e: React.DragEvent, id: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverId(id); + }; + + const handleDragLeave = () => { + setDragOverId(null); + }; + + const handleDrop = (e: React.DragEvent, targetId: string) => { + e.preventDefault(); + if (draggedId === null || draggedId === targetId) { + setDraggedId(null); + setDragOverId(null); + return; + } + + const currentOrder = [...orderedSidebarIds]; + const draggedIndex = currentOrder.indexOf(draggedId); + const targetIndex = currentOrder.indexOf(targetId); + + if (draggedIndex === -1 || targetIndex === -1) { + setDraggedId(null); + setDragOverId(null); + return; + } + + // Reorder + currentOrder.splice(draggedIndex, 1); + currentOrder.splice(targetIndex, 0, draggedId); + + // Save to localStorage and update state + setSidebarOrder(currentOrder); + saveSidebarOrder(currentOrder); + + setDraggedId(null); + setDragOverId(null); + }; + + const handleDragEnd = () => { + setDraggedId(null); + setDragOverId(null); + }; + // Show update banner if update available and not dismissed for this version const showUpdateBanner = updateCheck?.update_available && updateCheck.latest_version && @@ -132,45 +222,12 @@ export function Layout() { localStorage.setItem('sidebarExpanded', String(sidebarExpanded)); }, [sidebarExpanded]); - // Drag and drop handlers - const handleDragStart = (e: React.DragEvent, index: number) => { - setDraggedIndex(index); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', String(index)); - }; - - const handleDragOver = (e: React.DragEvent, index: number) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setDragOverIndex(index); - }; - - const handleDragLeave = () => { - setDragOverIndex(null); - }; - - const handleDrop = (e: React.DragEvent, dropIndex: number) => { - e.preventDefault(); - if (draggedIndex === null || draggedIndex === dropIndex) { - setDraggedIndex(null); - setDragOverIndex(null); - return; + // Close mobile drawer on navigation + useEffect(() => { + if (isMobile) { + setMobileDrawerOpen(false); } - - const newItems = [...navItems]; - const [draggedItem] = newItems.splice(draggedIndex, 1); - newItems.splice(dropIndex, 0, draggedItem); - - setNavItems(newItems); - saveNavOrder(newItems); - setDraggedIndex(null); - setDragOverIndex(null); - }; - - const handleDragEnd = () => { - setDraggedIndex(null); - setDragOverIndex(null); - }; + }, [location.pathname, isMobile]); // Global keyboard shortcuts for navigation const handleKeyDown = useCallback((e: KeyboardEvent) => { @@ -180,13 +237,17 @@ export function Layout() { return; } - // Number keys for navigation (1-6) - follows sidebar order + // Number keys for navigation (1-9) - follows sidebar order for internal nav items only if (!e.metaKey && !e.ctrlKey && !e.altKey) { const keyNum = parseInt(e.key); - if (keyNum >= 1 && keyNum <= navItems.length) { - e.preventDefault(); - navigate(navItems[keyNum - 1].to); - return; + const internalItems = orderedSidebarIds.filter(id => !isExternalLinkId(id)); + if (keyNum >= 1 && keyNum <= internalItems.length) { + const navItem = navItemsMap.get(internalItems[keyNum - 1]); + if (navItem) { + e.preventDefault(); + navigate(navItem.to); + return; + } } switch (e.key) { @@ -199,7 +260,7 @@ export function Layout() { break; } } - }, [navigate, navItems]); + }, [navigate, orderedSidebarIds, navItemsMap]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); @@ -208,77 +269,170 @@ export function Layout() { return (
- {/* Sidebar */} -