mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
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:
BIN
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
env/
|
||||
.env
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Node
|
||||
frontend/node_modules/
|
||||
frontend/.npm
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Logs and data (will be mounted as volumes)
|
||||
logs/
|
||||
data/
|
||||
*.log
|
||||
*.db
|
||||
|
||||
# Build artifacts
|
||||
static/
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
*.md
|
||||
!requirements.txt
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
+37
@@ -0,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"]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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")
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
sudo DOCKER_BUILDKIT=0 docker compose build
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user