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
This commit is contained in:
MartinNYHC
2025-12-10 09:57:55 +01:00
committed by GitHub
41 changed files with 10399 additions and 8861 deletions
BIN
View File
Binary file not shown.
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -94,15 +94,20 @@ async def create_notification_provider(
on_print_start=provider_data.on_print_start,
on_print_complete=provider_data.on_print_complete,
on_print_failed=provider_data.on_print_failed,
on_print_stopped=provider_data.on_print_stopped,
on_print_progress=provider_data.on_print_progress,
# Printer status events
on_printer_offline=provider_data.on_printer_offline,
on_printer_error=provider_data.on_printer_error,
on_filament_low=provider_data.on_filament_low,
on_maintenance_due=provider_data.on_maintenance_due,
# Quiet hours
quiet_hours_enabled=provider_data.quiet_hours_enabled,
quiet_hours_start=provider_data.quiet_hours_start,
quiet_hours_end=provider_data.quiet_hours_end,
# Daily digest
daily_digest_enabled=provider_data.daily_digest_enabled,
daily_digest_time=provider_data.daily_digest_time,
# Printer filter
printer_id=provider_data.printer_id,
)
+86 -50
View File
@@ -20,6 +20,7 @@ from backend.app.models.printer import Printer
from backend.app.models.filament import Filament
from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
from backend.app.models.archive import PrintArchive
from backend.app.models.external_link import ExternalLink
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
from backend.app.services.printer_manager import printer_manager
from backend.app.services.spoolman import init_spoolman_client, get_spoolman_client
@@ -167,6 +168,7 @@ async def export_backup(
include_notifications: bool = Query(True, description="Include notification providers"),
include_templates: bool = Query(True, description="Include notification templates"),
include_smart_plugs: bool = Query(True, description="Include smart plugs"),
include_external_links: bool = Query(True, description="Include external sidebar links"),
include_printers: bool = Query(False, description="Include printers (without access codes)"),
include_filaments: bool = Query(False, description="Include filament inventory"),
include_maintenance: bool = Query(False, description="Include maintenance types and records"),
@@ -258,6 +260,28 @@ async def export_backup(
})
backup["included"].append("smart_plugs")
# External links
if include_external_links:
result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order))
links = result.scalars().all()
backup["external_links"] = []
icons_dir = app_settings.base_dir / "icons"
for link in links:
link_data = {
"name": link.name,
"url": link.url,
"icon": link.icon,
"sort_order": link.sort_order,
}
# Include custom icon file path if exists
if link.custom_icon:
link_data["custom_icon"] = link.custom_icon
icon_path = icons_dir / link.custom_icon
if icon_path.exists():
link_data["custom_icon_path"] = f"icons/{link.custom_icon}"
backup["external_links"].append(link_data)
backup["included"].append("external_links")
# Printers (access codes only included if explicitly requested)
if include_printers:
result = await db.execute(select(Printer))
@@ -322,8 +346,19 @@ async def export_backup(
})
backup["included"].append("maintenance_types")
# Collect files for ZIP (icons + archives)
backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path)
# Add external link icon files
if include_external_links and "external_links" in backup:
icons_dir = app_settings.base_dir / "icons"
for link_data in backup["external_links"]:
if "custom_icon_path" in link_data:
icon_path = icons_dir / link_data["custom_icon"]
if icon_path.exists():
backup_files.append((link_data["custom_icon_path"], icon_path))
# Print archives with file paths for ZIP
archive_files: list[tuple[str, Path]] = [] # (zip_path, local_path)
if include_archives:
result = await db.execute(select(PrintArchive))
archives = result.scalars().all()
@@ -366,25 +401,25 @@ async def export_backup(
file_path = base_dir / a.file_path
if file_path.exists():
archive_data["file_path"] = a.file_path
archive_files.append((a.file_path, file_path))
backup_files.append((a.file_path, file_path))
if a.thumbnail_path:
thumb_path = base_dir / a.thumbnail_path
if thumb_path.exists():
archive_data["thumbnail_path"] = a.thumbnail_path
archive_files.append((a.thumbnail_path, thumb_path))
backup_files.append((a.thumbnail_path, thumb_path))
if a.timelapse_path:
timelapse_path = base_dir / a.timelapse_path
if timelapse_path.exists():
archive_data["timelapse_path"] = a.timelapse_path
archive_files.append((a.timelapse_path, timelapse_path))
backup_files.append((a.timelapse_path, timelapse_path))
if a.source_3mf_path:
source_path = base_dir / a.source_3mf_path
if source_path.exists():
archive_data["source_3mf_path"] = a.source_3mf_path
archive_files.append((a.source_3mf_path, source_path))
backup_files.append((a.source_3mf_path, source_path))
# Include photos
if a.photos:
@@ -392,21 +427,21 @@ async def export_backup(
photo_path = base_dir / "archive" / "photos" / photo
if photo_path.exists():
zip_photo_path = f"archive/photos/{photo}"
archive_files.append((zip_photo_path, photo_path))
backup_files.append((zip_photo_path, photo_path))
backup["archives"].append(archive_data)
backup["included"].append("archives")
# If archives included, create ZIP file with all files
if include_archives and archive_files:
# If there are files to include (icons or archives), create ZIP file
if backup_files:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
# Add backup.json
zf.writestr("backup.json", json.dumps(backup, indent=2))
# Add all archive files
# Add all backup files (icons, archives, etc.)
added_files = set()
for zip_path, local_path in archive_files:
for zip_path, local_path in backup_files:
if zip_path not in added_files and local_path.exists():
try:
zf.write(local_path, zip_path)
@@ -481,6 +516,7 @@ async def import_backup(
"notification_providers": 0,
"notification_templates": 0,
"smart_plugs": 0,
"external_links": 0,
"printers": 0,
"filaments": 0,
"maintenance_types": 0,
@@ -490,6 +526,7 @@ async def import_backup(
"notification_providers": 0,
"notification_templates": 0,
"smart_plugs": 0,
"external_links": 0,
"printers": 0,
"filaments": 0,
"maintenance_types": 0,
@@ -498,24 +535,13 @@ async def import_backup(
skipped_details = {
"notification_providers": [],
"smart_plugs": [],
"external_links": [],
"printers": [],
"filaments": [],
"maintenance_types": [],
"archives": [],
}
# Log what's in the backup
import logging
restore_logger = logging.getLogger(__name__)
restore_logger.info(f"Restore: Backup version={backup.get('version')}, included={backup.get('included', [])}")
restore_logger.info(f"Restore: overwrite={overwrite}")
if "printers" in backup:
restore_logger.info(f"Restore: Backup contains {len(backup['printers'])} printers")
for p in backup["printers"]:
restore_logger.info(f" - {p.get('name')}: access_code={'YES' if p.get('access_code') else 'NO'}, is_active={p.get('is_active')}")
else:
restore_logger.info("Restore: Backup does NOT contain printers")
# Restore settings (always overwrites)
if "settings" in backup:
for key, value in backup["settings"].items():
@@ -664,20 +690,49 @@ async def import_backup(
db.add(plug)
restored["smart_plugs"] += 1
# Restore printers (skip or overwrite duplicates by serial_number)
import logging
logger = logging.getLogger(__name__)
# Restore external links (skip or overwrite duplicates by name+url)
if "external_links" in backup:
icons_dir = base_dir / "icons"
icons_dir.mkdir(parents=True, exist_ok=True)
for link_data in backup["external_links"]:
result = await db.execute(
select(ExternalLink).where(
ExternalLink.name == link_data["name"],
ExternalLink.url == link_data["url"]
)
)
existing = result.scalar_one_or_none()
if existing:
if overwrite:
existing.icon = link_data.get("icon", "link")
existing.sort_order = link_data.get("sort_order", 0)
# Handle custom icon
if link_data.get("custom_icon"):
existing.custom_icon = link_data["custom_icon"]
restored["external_links"] += 1
else:
skipped["external_links"] += 1
skipped_details["external_links"].append(link_data["name"])
else:
link = ExternalLink(
name=link_data["name"],
url=link_data["url"],
icon=link_data.get("icon", "link"),
custom_icon=link_data.get("custom_icon"),
sort_order=link_data.get("sort_order", 0),
)
db.add(link)
restored["external_links"] += 1
# Restore printers (skip or overwrite duplicates by serial_number)
if "printers" in backup:
logger.info(f"Restore: Processing {len(backup['printers'])} printers from backup")
for printer_data in backup["printers"]:
logger.info(f"Restore: Processing printer {printer_data.get('name')} (serial: {printer_data.get('serial_number')})")
result = await db.execute(
select(Printer).where(Printer.serial_number == printer_data["serial_number"])
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Restore: Printer already exists (id={existing.id}, is_active={existing.is_active})")
if overwrite:
existing.name = printer_data["name"]
existing.ip_address = printer_data["ip_address"]
@@ -695,14 +750,11 @@ async def import_backup(
if isinstance(is_active_val, str):
is_active_val = is_active_val.lower() == "true"
existing.is_active = is_active_val
logger.info(f"Restore: Updated access_code and is_active={is_active_val} from backup")
restored["printers"] += 1
logger.info(f"Restore: Updated existing printer (overwrite=True)")
else:
skipped["printers"] += 1
skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
logger.info(f"Restore: Skipped existing printer (overwrite=False)")
else:
# Use access code from backup if provided, otherwise require manual setup
access_code = printer_data.get("access_code")
@@ -712,16 +764,6 @@ async def import_backup(
if isinstance(is_active_from_backup, str):
is_active_from_backup = is_active_from_backup.lower() == "true"
import logging
logger = logging.getLogger(__name__)
logger.info(f"Restore: Creating printer {printer_data['name']}")
logger.info(f" - access_code in backup: {'YES' if 'access_code' in printer_data else 'NO'}")
logger.info(f" - access_code value: {access_code[:4] + '...' if access_code and len(access_code) > 4 else access_code}")
logger.info(f" - has_access_code (valid): {has_access_code}")
logger.info(f" - is_active in backup: {printer_data.get('is_active')} (type: {type(printer_data.get('is_active')).__name__})")
logger.info(f" - is_active_from_backup (converted): {is_active_from_backup}")
logger.info(f" - final is_active: {is_active_from_backup if has_access_code else False}")
printer = Printer(
name=printer_data["name"],
serial_number=printer_data["serial_number"],
@@ -867,9 +909,6 @@ async def import_backup(
await db.commit()
import logging
logger = logging.getLogger(__name__)
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers
# This ensures connections are re-established after restore, even if printers were skipped
if "printers" in backup:
@@ -878,15 +917,12 @@ async def import_backup(
select(Printer).where(Printer.is_active == True)
)
active_printers = result.scalars().all()
logger.info(f"Restore: Found {len(active_printers)} active printers to reconnect")
for printer in active_printers:
logger.info(f"Restore: Reconnecting printer {printer.name} (id={printer.id}, ip={printer.ip_address}, access_code={'SET' if printer.access_code and printer.access_code != 'CHANGE_ME' else 'NOT SET'})")
# This will disconnect existing connection (if any) and reconnect
try:
connected = await printer_manager.connect_printer(printer)
logger.info(f"Restore: Printer {printer.name} connection result: {connected}")
except Exception as e:
logger.error(f"Restore: Failed to connect printer {printer.name}: {e}")
await printer_manager.connect_printer(printer)
except Exception:
pass # Connection failed, but don't fail the restore
# If settings were restored, check if Spoolman needs to be reconnected
if "settings" in backup:
+1 -1
View File
@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
import logging
# Application version - single source of truth
APP_VERSION = "0.1.5b"
APP_VERSION = "0.1.5b4"
GITHUB_REPO = "maziggy/bambuddy"
# Base directory for path calculations
+1 -1
View File
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template # noqa: F401
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+2 -1
View File
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
from backend.app.core.database import init_db, async_session
from sqlalchemy import select, or_
from backend.app.core.websocket import ws_manager
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links
from backend.app.api.routes import settings as settings_routes
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
@@ -1031,6 +1031,7 @@ app.include_router(spoolman.router, prefix=app_settings.api_prefix)
app.include_router(updates.router, prefix=app_settings.api_prefix)
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
app.include_router(camera.router, prefix=app_settings.api_prefix)
app.include_router(external_links.router, prefix=app_settings.api_prefix)
app.include_router(websocket.router, prefix=app_settings.api_prefix)
+25
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,3 @@
#!/bin/sh
sudo DOCKER_BUILDKIT=0 docker compose build
+16
View File
@@ -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
View File
@@ -9,6 +9,7 @@ import { SettingsPage } from './pages/SettingsPage';
import { ProfilesPage } from './pages/ProfilesPage';
import { MaintenancePage } from './pages/MaintenancePage';
import { CameraPage } from './pages/CameraPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -46,6 +47,7 @@ function App() {
<Route path="profiles" element={<ProfilesPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="external/:id" element={<ExternalLinkPage />} />
</Route>
</Routes>
</BrowserRouter>
+61
View File
@@ -881,6 +881,30 @@ export interface MaintenanceSummary {
}>;
}
// External Links (sidebar)
export interface ExternalLink {
id: number;
name: string;
url: string;
icon: string;
custom_icon: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface ExternalLinkCreate {
name: string;
url: string;
icon: string;
}
export interface ExternalLinkUpdate {
name?: string;
url?: string;
icon?: string;
}
// API functions
export const api = {
// Printers
@@ -1506,4 +1530,41 @@ export const api = {
`${API_BASE}/printers/${printerId}/camera/snapshot`,
testCameraConnection: (printerId: number) =>
request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
// External Links
getExternalLinks: () => request<ExternalLink[]>('/external-links/'),
getExternalLink: (id: number) => request<ExternalLink>(`/external-links/${id}`),
createExternalLink: (data: ExternalLinkCreate) =>
request<ExternalLink>('/external-links/', {
method: 'POST',
body: JSON.stringify(data),
}),
updateExternalLink: (id: number, data: ExternalLinkUpdate) =>
request<ExternalLink>(`/external-links/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteExternalLink: (id: number) =>
request<{ message: string }>(`/external-links/${id}`, { method: 'DELETE' }),
reorderExternalLinks: (ids: number[]) =>
request<ExternalLink[]>('/external-links/reorder', {
method: 'PUT',
body: JSON.stringify({ ids }),
}),
uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
deleteExternalLinkIcon: (id: number) =>
request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`,
};
@@ -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
View File
@@ -26,9 +26,9 @@ export function Button({
};
const sizes = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-6 py-3 text-base gap-2',
sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0',
md: 'px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0',
lg: 'px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0',
};
return (
+1 -1
View File
@@ -93,7 +93,7 @@ function SortableWidget({
className="cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
<GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
</button>
<h3 className="text-sm font-medium text-white">{title}</h3>
</div>
@@ -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
View File
@@ -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>
);
}
+292 -128
View File
@@ -1,11 +1,13 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, Menu, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import { getIconByName } from './IconPicker';
import { useIsMobile } from '../hooks/useIsMobile';
interface NavItem {
id: string;
@@ -24,36 +26,27 @@ export const defaultNavItems: NavItem[] = [
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
// Get ordered nav items from localStorage
function getOrderedNavItems(): NavItem[] {
// Get unified sidebar order from localStorage
function getSidebarOrder(): string[] {
const stored = localStorage.getItem('sidebarOrder');
if (stored) {
try {
const order: string[] = JSON.parse(stored);
const itemMap = new Map(defaultNavItems.map(item => [item.id, item]));
const ordered: NavItem[] = [];
for (const id of order) {
const item = itemMap.get(id);
if (item) {
ordered.push(item);
itemMap.delete(id);
}
}
// Add any new items that weren't in the stored order
for (const item of itemMap.values()) {
ordered.push(item);
}
return ordered;
return JSON.parse(stored);
} catch {
return defaultNavItems;
return defaultNavItems.map(i => i.id);
}
}
return defaultNavItems;
return defaultNavItems.map(i => i.id);
}
// Save nav item order to localStorage
function saveNavOrder(items: NavItem[]) {
localStorage.setItem('sidebarOrder', JSON.stringify(items.map(i => i.id)));
// Save unified sidebar order to localStorage
function saveSidebarOrder(order: string[]) {
localStorage.setItem('sidebarOrder', JSON.stringify(order));
}
// Check if an ID is an external link
function isExternalLinkId(id: string): boolean {
return id.startsWith('ext-');
}
// Get default view from localStorage
@@ -71,14 +64,16 @@ export function Layout() {
const location = useLocation();
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation();
const isMobile = useIsMobile();
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
});
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [navItems, setNavItems] = useState<NavItem[]>(getOrderedNavItems);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const hasRedirected = useRef(false);
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
sessionStorage.getItem('dismissedUpdateVersion')
@@ -105,6 +100,101 @@ export function Layout() {
refetchInterval: 60 * 60 * 1000, // Check every hour
});
// Fetch external links for sidebar
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
queryFn: api.getExternalLinks,
});
// Build the unified sidebar items list
const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
// Compute the ordered sidebar: include stored order + any new items
const orderedSidebarIds = (() => {
const result: string[] = [];
const seen = new Set<string>();
// Add items in stored order
for (const id of sidebarOrder) {
if (navItemsMap.has(id) || extLinksMap.has(id)) {
result.push(id);
seen.add(id);
}
}
// Add any new internal nav items not in stored order
for (const item of defaultNavItems) {
if (!seen.has(item.id)) {
result.push(item.id);
seen.add(item.id);
}
}
// Add any new external links not in stored order
for (const link of externalLinks || []) {
const extId = `ext-${link.id}`;
if (!seen.has(extId)) {
result.push(extId);
seen.add(extId);
}
}
return result;
})();
// Unified drag handlers
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(id);
};
const handleDragLeave = () => {
setDragOverId(null);
};
const handleDrop = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
if (draggedId === null || draggedId === targetId) {
setDraggedId(null);
setDragOverId(null);
return;
}
const currentOrder = [...orderedSidebarIds];
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedId(null);
setDragOverId(null);
return;
}
// Reorder
currentOrder.splice(draggedIndex, 1);
currentOrder.splice(targetIndex, 0, draggedId);
// Save to localStorage and update state
setSidebarOrder(currentOrder);
saveSidebarOrder(currentOrder);
setDraggedId(null);
setDragOverId(null);
};
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
// Show update banner if update available and not dismissed for this version
const showUpdateBanner = updateCheck?.update_available &&
updateCheck.latest_version &&
@@ -132,45 +222,12 @@ export function Layout() {
localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
}, [sidebarExpanded]);
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
// Close mobile drawer on navigation
useEffect(() => {
if (isMobile) {
setMobileDrawerOpen(false);
}
const newItems = [...navItems];
const [draggedItem] = newItems.splice(draggedIndex, 1);
newItems.splice(dropIndex, 0, draggedItem);
setNavItems(newItems);
saveNavOrder(newItems);
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
}, [location.pathname, isMobile]);
// Global keyboard shortcuts for navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -180,13 +237,17 @@ export function Layout() {
return;
}
// Number keys for navigation (1-6) - follows sidebar order
// Number keys for navigation (1-9) - follows sidebar order for internal nav items only
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
const keyNum = parseInt(e.key);
if (keyNum >= 1 && keyNum <= navItems.length) {
e.preventDefault();
navigate(navItems[keyNum - 1].to);
return;
const internalItems = orderedSidebarIds.filter(id => !isExternalLinkId(id));
if (keyNum >= 1 && keyNum <= internalItems.length) {
const navItem = navItemsMap.get(internalItems[keyNum - 1]);
if (navItem) {
e.preventDefault();
navigate(navItem.to);
return;
}
}
switch (e.key) {
@@ -199,7 +260,7 @@ export function Layout() {
break;
}
}
}, [navigate, navItems]);
}, [navigate, orderedSidebarIds, navItemsMap]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
@@ -208,77 +269,170 @@ export function Layout() {
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<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`}
>
{/* Logo */}
<div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${sidebarExpanded ? 'p-4' : 'p-2'}`}>
{/* 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={sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
className="h-8 ml-3"
/>
</header>
)}
{/* Mobile Drawer Backdrop */}
{isMobile && mobileDrawerOpen && (
<div
className="fixed inset-0 bg-black/60 z-40 transition-opacity"
onClick={() => setMobileDrawerOpen(false)}
/>
)}
{/* Sidebar / Mobile Drawer */}
<aside
className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
isMobile
? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
: `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
}`}
>
{/* Logo */}
<div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
<img
src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
/>
</div>
{/* Navigation */}
<nav className="flex-1 p-2">
<ul className="space-y-2">
{navItems.map(({ id, to, icon: Icon, labelKey }, index) => (
<li
key={id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
className={`relative ${
draggedIndex === index ? 'opacity-50' : ''
} ${
dragOverIndex === index && draggedIndex !== index
? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<NavLink
to={to}
className={({ isActive }) =>
`flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!sidebarExpanded ? t(labelKey) : undefined}
>
{sidebarExpanded && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
<Icon className="w-5 h-5 flex-shrink-0" />
{sidebarExpanded && <span>{t(labelKey)}</span>}
</NavLink>
</li>
))}
{orderedSidebarIds.map((id) => {
const isExternal = isExternalLinkId(id);
if (isExternal) {
// Render external link
const link = extLinksMap.get(id);
if (!link) return null;
const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
return (
<li
key={id}
draggable
onDragStart={(e) => handleDragStart(e, id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, id)}
onDragEnd={handleDragEnd}
className={`relative ${
draggedId === id ? 'opacity-50' : ''
} ${
dragOverId === id && draggedId !== id
? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<NavLink
to={`/external/${link.id}`}
className={({ isActive }) =>
`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!isMobile && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className={`w-5 h-5 flex-shrink-0 ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isMobile || sidebarExpanded) && <span>{link.name}</span>}
</NavLink>
</li>
);
} else {
// Render internal nav item
const navItem = navItemsMap.get(id);
if (!navItem) return null;
const { to, icon: Icon, labelKey } = navItem;
return (
<li
key={id}
draggable
onDragStart={(e) => handleDragStart(e, id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, id)}
onDragEnd={handleDragEnd}
className={`relative ${
draggedId === id ? 'opacity-50' : ''
} ${
dragOverId === id && draggedId !== id
? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<NavLink
to={to}
className={({ isActive }) =>
`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!isMobile && !sidebarExpanded ? t(labelKey) : undefined}
>
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
<Icon className="w-5 h-5 flex-shrink-0" />
{(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
</NavLink>
</li>
);
}
})}
</ul>
</nav>
{/* Collapse toggle */}
<button
onClick={() => setSidebarExpanded(!sidebarExpanded)}
className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
>
{sidebarExpanded ? (
<ChevronLeft className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
{/* Collapse toggle - hide on mobile */}
{!isMobile && (
<button
onClick={() => setSidebarExpanded(!sidebarExpanded)}
className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
>
{sidebarExpanded ? (
<ChevronLeft className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
)}
{/* Footer */}
<div className="p-2 border-t border-bambu-dark-tertiary">
{sidebarExpanded ? (
{isMobile || sidebarExpanded ? (
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
@@ -359,7 +513,9 @@ export function Layout() {
</aside>
{/* Main content */}
<main className={`flex-1 bg-bambu-dark overflow-auto ${sidebarExpanded ? 'ml-64' : 'ml-16'} transition-all duration-300`}>
<main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
isMobile ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
}`}>
{/* Persistent update banner */}
{showUpdateBanner && (
<div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
@@ -391,7 +547,15 @@ export function Layout() {
</main>
{/* Keyboard Shortcuts Modal */}
{showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} navItems={navItems} />}
{showShortcuts && (
<KeyboardShortcutsModal
onClose={() => setShowShortcuts(false)}
navItems={orderedSidebarIds
.filter(id => !isExternalLinkId(id))
.map(id => navItemsMap.get(id)!)
.filter(Boolean)}
/>
)}
</div>
);
}
+2 -2
View File
@@ -20,7 +20,7 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
aria-checked={checked}
disabled={disabled}
onClick={handleClick}
className={`relative inline-flex w-9 h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
className={`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
disabled
? 'bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50'
: checked
@@ -29,7 +29,7 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
}`}
>
<span
className={`pointer-events-none absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${
className={`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${
checked ? 'translate-x-4' : 'translate-x-0'
}`}
/>
+26
View File
@@ -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
View File
@@ -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
View File
@@ -143,3 +143,41 @@ body {
#333 4px
);
}
/* Touch manipulation to prevent zoom on double-tap */
.touch-manipulation {
touch-action: manipulation;
}
/* Safe area insets for notched devices */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Mobile drawer animation */
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-left {
animation: slide-in-left 0.3s ease-out;
}
+37 -16
View File
@@ -35,8 +35,10 @@ import {
Camera,
FileText,
FileCode,
MoreVertical,
} from 'lucide-react';
import { api } from '../api/client';
import { useIsMobile } from '../hooks/useIsMobile';
import type { Archive } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
@@ -93,6 +95,7 @@ function ArchiveCard({
}) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const isMobile = useIsMobile();
const [showViewer, setShowViewer] = useState(false);
const [showReprint, setShowReprint] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -355,6 +358,20 @@ function ArchiveCard({
<Image className="w-12 h-12 text-bambu-dark-tertiary" />
</div>
)}
{/* Mobile menu button */}
{isMobile && (
<button
className="absolute top-2 right-10 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-colors"
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({ x: rect.left, y: rect.bottom + 4 });
}}
title="Menu"
>
<MoreVertical className="w-5 h-5 text-white" />
</button>
)}
{/* Favorite star */}
<button
className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
@@ -1069,7 +1086,7 @@ export function ArchivesPage() {
return (
<div
className="p-8 relative min-h-full"
className="p-4 md:p-8 relative min-h-full"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -1175,20 +1192,23 @@ export function ArchivesPage() {
{/* Filters */}
<Card className="mb-6">
<CardContent className="py-4">
<div className="flex gap-4 items-center flex-wrap">
<div className="flex-1 relative min-w-[200px]">
<div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
{/* Search - full width on mobile */}
<div className="w-full md:flex-1 relative md:min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
ref={searchInputRef}
type="text"
placeholder="Search archives... (press /)"
className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
placeholder="Search archives..."
className="w-full pl-10 pr-4 py-3 md:py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-bambu-gray" />
{/* Filters - horizontal scroll on mobile */}
<div className="flex gap-2 md:gap-4 overflow-x-auto pb-1 md:pb-0 -mx-4 px-4 md:mx-0 md:px-0 md:flex-wrap scrollbar-hide">
<div className="flex items-center gap-2 flex-shrink-0">
<Filter className="w-4 h-4 text-bambu-gray hidden md:block" />
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterPrinter || ''}
@@ -1204,8 +1224,8 @@ export function ArchivesPage() {
))}
</select>
</div>
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-bambu-gray" />
<div className="flex items-center gap-2 flex-shrink-0">
<Package className="w-4 h-4 text-bambu-gray hidden md:block" />
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterMaterial || ''}
@@ -1223,7 +1243,7 @@ export function ArchivesPage() {
</div>
<button
onClick={() => setFilterFavorites(!filterFavorites)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${
filterFavorites
? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -1231,11 +1251,11 @@ export function ArchivesPage() {
title={filterFavorites ? 'Show all' : 'Show favorites only'}
>
<Star className={`w-4 h-4 ${filterFavorites ? 'fill-yellow-400' : ''}`} />
<span className="text-sm">Favorites</span>
<span className="text-sm hidden md:inline">Favorites</span>
</button>
{uniqueTags.length > 0 && (
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-bambu-gray" />
<div className="flex items-center gap-2 flex-shrink-0">
<Tag className="w-4 h-4 text-bambu-gray hidden md:block" />
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterTag || ''}
@@ -1250,8 +1270,8 @@ export function ArchivesPage() {
</select>
</div>
)}
<div className="flex items-center gap-2">
<ArrowUpDown className="w-4 h-4 text-bambu-gray" />
<div className="flex items-center gap-2 flex-shrink-0">
<ArrowUpDown className="w-4 h-4 text-bambu-gray hidden md:block" />
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={sortBy}
@@ -1265,7 +1285,7 @@ export function ArchivesPage() {
<option value="size-asc">Smallest first</option>
</select>
</div>
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0">
<button
className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('grid')}
@@ -1288,6 +1308,7 @@ export function ArchivesPage() {
<CalendarDays className="w-4 h-4" />
</button>
</div>
</div>
{hasTopFilters && (
<Button
variant="ghost"
+43
View File
@@ -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
View File
@@ -914,7 +914,7 @@ export function MaintenancePage() {
if (isLoading) {
return (
<div className="p-8 flex justify-center">
<div className="p-4 md:p-8 flex justify-center">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
@@ -924,7 +924,7 @@ export function MaintenancePage() {
const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
return (
<div className="p-8">
<div className="p-4 md:p-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Maintenance</h1>
+41 -30
View File
@@ -1013,41 +1013,52 @@ function PrinterCard({
)}
</div>
{/* Label and humidity/temp */}
{/* Label and filament info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-xs text-bambu-gray font-medium">
{getAmsLabel(ams.id, ams.tray.length)}
</span>
{(ams.humidity != null || ams.temp != null) && (
<div className="flex items-center gap-2 text-xs">
{ams.humidity != null && (
<HumidityIndicator
humidity={ams.humidity}
goodThreshold={amsThresholds?.humidityGood}
fairThreshold={amsThresholds?.humidityFair}
/>
)}
{ams.temp != null && (
<TemperatureIndicator
temp={ams.temp}
goodThreshold={amsThresholds?.tempGood}
fairThreshold={amsThresholds?.tempFair}
/>
<span className="text-xs text-bambu-gray font-medium">
{getAmsLabel(ams.id, ams.tray.length)}
</span>
{/* Filament types and fill levels */}
<div className="mt-0.5 text-[10px] flex items-start">
{ams.tray.map((tray, i) => (
<div key={i} className="flex items-start">
<div className="flex flex-col">
<span className="text-bambu-gray/70 truncate">
{tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'}
</span>
<span className="text-bambu-gray/50 truncate">
{tray.tray_type && tray.remain >= 0 ? `${tray.remain}%` : '—'}
</span>
</div>
{i < ams.tray.length - 1 && (
<span className="text-bambu-gray/50 mx-1 flex flex-col">
<span>·</span>
<span>·</span>
</span>
)}
</div>
)}
</div>
{/* Filament tooltips as small text */}
<div className="flex gap-1 mt-0.5 text-[10px] text-bambu-gray/70 truncate">
{ams.tray.map((tray, i) => (
<span key={i} className="truncate">
{tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'}
{i < ams.tray.length - 1 && ' · '}
</span>
))}
</div>
</div>
{/* Humidity/temp - vertically centered */}
{(ams.humidity != null || ams.temp != null) && (
<div className="flex items-center gap-2 text-xs flex-shrink-0">
{ams.humidity != null && (
<HumidityIndicator
humidity={ams.humidity}
goodThreshold={amsThresholds?.humidityGood}
fairThreshold={amsThresholds?.humidityFair}
/>
)}
{ams.temp != null && (
<TemperatureIndicator
temp={ams.temp}
goodThreshold={amsThresholds?.tempGood}
fairThreshold={amsThresholds?.tempFair}
/>
)}
</div>
)}
</div>
</div>
);
@@ -1819,7 +1830,7 @@ export function PrintersPage() {
}, [sortBy, sortedPrinters]);
return (
<div className="p-8">
<div className="p-4 md:p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Printers</h1>
+1 -1
View File
@@ -2788,7 +2788,7 @@ export function ProfilesPage() {
if (statusLoading) {
return (
<div className="p-8 flex items-center justify-center min-h-[400px]">
<div className="p-4 md:p-8 flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
+3 -3
View File
@@ -144,9 +144,9 @@ function SortableQueueItem({
<div
{...attributes}
{...listeners}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors"
className="flex items-center justify-center w-10 h-10 md:w-8 md:h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation"
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
<GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
</div>
) : position !== undefined ? (
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
@@ -505,7 +505,7 @@ export function QueuePage() {
};
return (
<div className="p-8">
<div className="p-4 md:p-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
+9 -6
View File
@@ -15,6 +15,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
import { BackupModal } from '../components/BackupModal';
import { RestoreModal } from '../components/RestoreModal';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
import { availableLanguages } from '../i18n';
import { useToast } from '../contexts/ToastContext';
@@ -304,14 +305,14 @@ export function SettingsPage() {
if (isLoading || !localSettings) {
return (
<div className="p-8 flex justify-center">
<div className="p-4 md:p-8 flex justify-center">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
return (
<div className="p-8">
<div className="p-4 md:p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-bambu-gray">Configure Bambuddy</p>
@@ -365,9 +366,9 @@ export function SettingsPage() {
{/* General Tab */}
{activeTab === 'general' && (
<div className="flex gap-8">
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
{/* Left Column - General Settings */}
<div className="space-y-6 flex-1 max-w-xl">
<div className="space-y-6 flex-1 lg:max-w-xl">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
@@ -629,7 +630,7 @@ export function SettingsPage() {
</div>
{/* Second Column - AMS & Spoolman */}
<div className="space-y-6 flex-1 max-w-md">
<div className="space-y-6 flex-1 lg:max-w-md">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
@@ -734,10 +735,12 @@ export function SettingsPage() {
</Card>
<SpoolmanSettings />
<ExternalLinksSettings />
</div>
{/* Third Column - Updates */}
<div className="space-y-6 flex-1 max-w-sm">
<div className="space-y-6 flex-1 lg:max-w-sm">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Updates</h2>
+2 -2
View File
@@ -338,7 +338,7 @@ export function StatsPage() {
if (isLoading) {
return (
<div className="p-8">
<div className="p-4 md:p-8">
<div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
</div>
);
@@ -392,7 +392,7 @@ export function StatsPage() {
];
return (
<div className="p-8">
<div className="p-4 md:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
@@ -0,0 +1 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g><path d="m280.199 256c0-36.303-29.535-65.838-65.838-65.838s-65.838 29.535-65.838 65.838 29.535 65.838 65.838 65.838 65.838-29.535 65.838-65.838zm-101.676 0c0-19.761 16.077-35.838 35.838-35.838s35.838 16.077 35.838 35.838-16.077 35.838-35.838 35.838-35.838-16.077-35.838-35.838z"/><path d="m292.904 192.457c3.839 0 7.678-1.464 10.606-4.394l20-20c5.858-5.858 5.858-15.355 0-21.213-5.857-5.858-15.355-5.858-21.213 0l-20 20c-5.858 5.858-5.858 15.355 0 21.213 2.929 2.93 6.768 4.394 10.607 4.394z"/><path d="m125.211 188.063c2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394c5.858-5.858 5.858-15.355 0-21.213l-20-20c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213z"/><path d="m303.51 323.937c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213l20 20c2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394c5.858-5.858 5.858-15.355 0-21.213z"/><path d="m125.211 323.937-20 20c-5.858 5.858-5.858 15.355 0 21.213 2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394l20-20c5.858-5.858 5.858-15.355 0-21.213-5.856-5.859-15.354-5.859-21.212 0z"/><path d="m310.438 256c0 8.284 6.716 15 15 15h28.284c8.284 0 15-6.716 15-15s-6.716-15-15-15h-28.284c-8.285 0-15 6.716-15 15z"/><path d="m214.361 159.923c8.284 0 15-6.716 15-15v-28.284c0-8.284-6.716-15-15-15s-15 6.716-15 15v28.284c0 8.285 6.716 15 15 15z"/><path d="m229.361 395.361v-28.284c0-8.284-6.716-15-15-15s-15 6.716-15 15v28.284c0 8.284 6.716 15 15 15s15-6.716 15-15z"/><path d="m118.284 256c0-8.284-6.716-15-15-15h-28.284c-8.284 0-15 6.716-15 15s6.716 15 15 15h28.284c8.284 0 15-6.716 15-15z"/><path d="m497 440.361h-173.11c15.138-9.029 29.259-19.997 42.047-32.785 40.487-40.487 62.785-94.318 62.785-151.576s-22.297-111.089-62.785-151.576-94.318-62.785-151.576-62.785-111.089 22.298-151.576 62.785-62.785 94.318-62.785 151.576 22.297 111.089 62.785 151.576 94.318 62.785 151.576 62.785h282.639c8.284 0 15-6.716 15-15s-6.716-15-15-15zm-467-184.361c0-101.657 82.704-184.361 184.361-184.361s184.361 82.704 184.361 184.361-82.704 184.361-184.361 184.361-184.361-82.704-184.361-184.361z"/></g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g><path d="m280.199 256c0-36.303-29.535-65.838-65.838-65.838s-65.838 29.535-65.838 65.838 29.535 65.838 65.838 65.838 65.838-29.535 65.838-65.838zm-101.676 0c0-19.761 16.077-35.838 35.838-35.838s35.838 16.077 35.838 35.838-16.077 35.838-35.838 35.838-35.838-16.077-35.838-35.838z"/><path d="m292.904 192.457c3.839 0 7.678-1.464 10.606-4.394l20-20c5.858-5.858 5.858-15.355 0-21.213-5.857-5.858-15.355-5.858-21.213 0l-20 20c-5.858 5.858-5.858 15.355 0 21.213 2.929 2.93 6.768 4.394 10.607 4.394z"/><path d="m125.211 188.063c2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394c5.858-5.858 5.858-15.355 0-21.213l-20-20c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213z"/><path d="m303.51 323.937c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213l20 20c2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394c5.858-5.858 5.858-15.355 0-21.213z"/><path d="m125.211 323.937-20 20c-5.858 5.858-5.858 15.355 0 21.213 2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394l20-20c5.858-5.858 5.858-15.355 0-21.213-5.856-5.859-15.354-5.859-21.212 0z"/><path d="m310.438 256c0 8.284 6.716 15 15 15h28.284c8.284 0 15-6.716 15-15s-6.716-15-15-15h-28.284c-8.285 0-15 6.716-15 15z"/><path d="m214.361 159.923c8.284 0 15-6.716 15-15v-28.284c0-8.284-6.716-15-15-15s-15 6.716-15 15v28.284c0 8.285 6.716 15 15 15z"/><path d="m229.361 395.361v-28.284c0-8.284-6.716-15-15-15s-15 6.716-15 15v28.284c0 8.284 6.716 15 15 15s15-6.716 15-15z"/><path d="m118.284 256c0-8.284-6.716-15-15-15h-28.284c-8.284 0-15 6.716-15 15s6.716 15 15 15h28.284c8.284 0 15-6.716 15-15z"/><path d="m497 440.361h-173.11c15.138-9.029 29.259-19.997 42.047-32.785 40.487-40.487 62.785-94.318 62.785-151.576s-22.297-111.089-62.785-151.576-94.318-62.785-151.576-62.785-111.089 22.298-151.576 62.785-62.785 94.318-62.785 151.576 22.297 111.089 62.785 151.576 94.318 62.785 151.576 62.785h282.639c8.284 0 15-6.716 15-15s-6.716-15-15-15zm-467-184.361c0-101.657 82.704-184.361 184.361-184.361s184.361 82.704 184.361 184.361-82.704 184.361-184.361 184.361-184.361-82.704-184.361-184.361z"/></g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g><path d="m280.199 256c0-36.303-29.535-65.838-65.838-65.838s-65.838 29.535-65.838 65.838 29.535 65.838 65.838 65.838 65.838-29.535 65.838-65.838zm-101.676 0c0-19.761 16.077-35.838 35.838-35.838s35.838 16.077 35.838 35.838-16.077 35.838-35.838 35.838-35.838-16.077-35.838-35.838z"/><path d="m292.904 192.457c3.839 0 7.678-1.464 10.606-4.394l20-20c5.858-5.858 5.858-15.355 0-21.213-5.857-5.858-15.355-5.858-21.213 0l-20 20c-5.858 5.858-5.858 15.355 0 21.213 2.929 2.93 6.768 4.394 10.607 4.394z"/><path d="m125.211 188.063c2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394c5.858-5.858 5.858-15.355 0-21.213l-20-20c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213z"/><path d="m303.51 323.937c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213l20 20c2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394c5.858-5.858 5.858-15.355 0-21.213z"/><path d="m125.211 323.937-20 20c-5.858 5.858-5.858 15.355 0 21.213 2.929 2.929 6.768 4.394 10.606 4.394s7.678-1.464 10.606-4.394l20-20c5.858-5.858 5.858-15.355 0-21.213-5.856-5.859-15.354-5.859-21.212 0z"/><path d="m310.438 256c0 8.284 6.716 15 15 15h28.284c8.284 0 15-6.716 15-15s-6.716-15-15-15h-28.284c-8.285 0-15 6.716-15 15z"/><path d="m214.361 159.923c8.284 0 15-6.716 15-15v-28.284c0-8.284-6.716-15-15-15s-15 6.716-15 15v28.284c0 8.285 6.716 15 15 15z"/><path d="m229.361 395.361v-28.284c0-8.284-6.716-15-15-15s-15 6.716-15 15v28.284c0 8.284 6.716 15 15 15s15-6.716 15-15z"/><path d="m118.284 256c0-8.284-6.716-15-15-15h-28.284c-8.284 0-15 6.716-15 15s6.716 15 15 15h28.284c8.284 0 15-6.716 15-15z"/><path d="m497 440.361h-173.11c15.138-9.029 29.259-19.997 42.047-32.785 40.487-40.487 62.785-94.318 62.785-151.576s-22.297-111.089-62.785-151.576-94.318-62.785-151.576-62.785-111.089 22.298-151.576 62.785-62.785 94.318-62.785 151.576 22.297 111.089 62.785 151.576 94.318 62.785 151.576 62.785h282.639c8.284 0 15-6.716 15-15s-6.716-15-15-15zm-467-184.361c0-101.657 82.704-184.361 184.361-184.361s184.361 82.704 184.361 184.361-82.704 184.361-184.361 184.361-184.361-82.704-184.361-184.361z"/></g><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/><g/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

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