diff --git a/backend/alembic/versions/002_custom_projects.py b/backend/alembic/versions/002_custom_projects.py new file mode 100644 index 0000000..8d84d76 --- /dev/null +++ b/backend/alembic/versions/002_custom_projects.py @@ -0,0 +1,65 @@ +"""Add custom projects support: is_custom column on projects, project_parts table, ldraw_part_index table. + +Revision ID: 002_custom_projects +Revises: 001_initial +Create Date: 2026-02-23 +""" +from typing import Sequence, Union +from alembic import op +from sqlalchemy import inspect +import sqlalchemy as sa + + +revision: str = "002_custom_projects" +down_revision: Union[str, None] = "001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_exists(conn, table: str, column: str) -> bool: + cols = [c["name"] for c in inspect(conn).get_columns(table)] + return column in cols + + +def _tables_exist(conn): + return set(inspect(conn).get_table_names()) + + +def upgrade() -> None: + conn = op.get_bind() + existing = _tables_exist(conn) + + # Add is_custom column to projects if not present + if "projects" in existing and not _column_exists(conn, "projects", "is_custom"): + op.add_column("projects", sa.Column("is_custom", sa.Boolean(), nullable=False, server_default="0")) + + if "project_parts" not in existing: + op.create_table( + "project_parts", + sa.Column("id", sa.String(), nullable=False), + sa.Column("project_id", sa.String(), nullable=False), + sa.Column("part_num", sa.String(), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False, server_default="1"), + sa.Column("color", sa.String(), nullable=True), + sa.Column("color_rgb", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_project_parts_project_id"), "project_parts", ["project_id"], unique=False) + + if "ldraw_part_index" not in existing: + op.create_table( + "ldraw_part_index", + sa.Column("part_num", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("indexed_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("part_num"), + ) + + +def downgrade() -> None: + op.drop_table("ldraw_part_index") + op.drop_index(op.f("ix_project_parts_project_id"), table_name="project_parts") + op.drop_table("project_parts") + # SQLite doesn't support DROP COLUMN in older versions; skip for now diff --git a/backend/api/integrations/ldraw.py b/backend/api/integrations/ldraw.py index 68e3b29..0ad6dac 100644 --- a/backend/api/integrations/ldraw.py +++ b/backend/api/integrations/ldraw.py @@ -2,8 +2,9 @@ import logging import asyncio import re +from datetime import datetime from pathlib import Path -from typing import Optional +from typing import Optional, List, Dict import aiohttp import zipfile import io @@ -16,6 +17,95 @@ PARTS_UPDATE_PATTERN = re.compile(r"Parts Update\s+(\d{4}-\d{2})", re.IGNORECASE) +def _parse_dat_description(path: Path) -> Optional[str]: + """Return the description from the first line of an LDraw .dat file. + + LDraw .dat files start with a line like: + 0 Brick 2 x 4 + where '0' is the line type and the rest is the description. + """ + try: + with path.open("r", encoding="utf-8", errors="replace") as fh: + for raw in fh: + line = raw.strip() + if not line: + continue + # LDraw line type 0 = meta/comment; first such line is the description. + # Skip pure meta-keywords: comment markers (//) and file references (FILE). + _SKIP_PREFIXES = ("//", "FILE", "!LDRAW_ORG", "!LICENSE", "!CATEGORY", "!KEYWORDS", "!HISTORY") + parts = line.split(None, 1) + if parts and parts[0] == "0" and len(parts) > 1: + desc = parts[1].strip() + if desc and not any(desc.startswith(p) for p in _SKIP_PREFIXES): + return desc + break + except Exception: + pass + return None + + +def build_ldraw_part_index(db, parts_dir: Optional[Path] = None) -> int: + """Scan the LDraw parts directory and populate the ldraw_part_index table. + + Returns the number of parts indexed. + """ + from backend.database import LDrawPartIndex + + if parts_dir is None: + parts_dir = settings.ldraw_library_path / "parts" + + if not parts_dir.exists(): + logger.warning(f"LDraw parts directory not found: {parts_dir}") + return 0 + + now = datetime.utcnow() + count = 0 + + for dat_file in sorted(parts_dir.glob("*.dat")): + part_num = dat_file.stem.lower() + description = _parse_dat_description(dat_file) + # Upsert: update description if part already indexed + existing = db.query(LDrawPartIndex).filter(LDrawPartIndex.part_num == part_num).first() + if existing: + existing.description = description + existing.indexed_at = now + else: + db.add(LDrawPartIndex(part_num=part_num, description=description, indexed_at=now)) + count += 1 + if count % 500 == 0: + db.flush() + + db.commit() + logger.info(f"Indexed {count} LDraw parts") + return count + + +def search_ldraw_part_index(db, query: str, limit: int = 20) -> List[Dict]: + """Search the ldraw_part_index table by part_num or description. + + Returns a list of dicts with 'part_num' and 'description'. + """ + from backend.database import LDrawPartIndex + from sqlalchemy import or_ + + q = query.strip().lower() + if not q: + return [] + + rows = ( + db.query(LDrawPartIndex) + .filter( + or_( + LDrawPartIndex.part_num.ilike(f"%{q}%"), + LDrawPartIndex.description.ilike(f"%{q}%"), + ) + ) + .limit(limit) + .all() + ) + return [{"part_num": r.part_num, "description": r.description} for r in rows] + + class LDrawManager: """Manages LDraw parts library and conversions.""" diff --git a/backend/api/routes/generate.py b/backend/api/routes/generate.py index d75a579..25f7fbd 100644 --- a/backend/api/routes/generate.py +++ b/backend/api/routes/generate.py @@ -57,6 +57,30 @@ def start_generation( ) +def start_generation_custom( + job_id: str, + parts: list, + plate_width: int, + plate_depth: int, + plate_height: int, + bypass_cache: bool = False, + generate_3mf: bool = True, + generate_stl: bool = True, +) -> None: + """Run process_generation_custom in a background thread (custom project with pre-selected parts).""" + run_async_in_background_thread( + process_generation_custom, + job_id, + parts, + plate_width, + plate_depth, + plate_height, + bypass_cache, + generate_3mf, + generate_stl, + ) + + async def process_generation( job_id: str, set_num: str, @@ -395,6 +419,314 @@ def _exit_if_cancelled() -> bool: db.close() +async def process_generation_custom( + job_id: str, + parts: list, + plate_width: int, + plate_depth: int, + plate_height: int, + bypass_cache: bool = False, + generate_3mf: bool = True, + generate_stl: bool = True, +): + """Background task to generate output for a custom project using a pre-supplied parts list. + + The ``parts`` argument is a list of dicts with keys: + part_num, ldraw_id, quantity, color, color_rgb, is_spare. + Processing mirrors process_generation() but skips the Rebrickable API call. + """ + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + engine = create_engine( + f"sqlite:///{settings.database_path}", + connect_args={"check_same_thread": False} + ) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = SessionLocal() + job_log = [] + + def _is_job_cancelled() -> bool: + j = db.query(Job).filter(Job.id == job_id).first() + return j is not None and getattr(j, "status", None) == "cancelled" + + def _exit_if_cancelled() -> bool: + if _is_job_cancelled(): + remove_job_progress(job_id) + db.close() + return True + return False + + try: + job = db.query(Job).filter(Job.id == job_id).first() + if not job: + logger.error(f"Job {job_id} not found") + return + job.status = "processing" + job.progress = 0 + db.commit() + if _exit_if_cancelled(): + return + + job_log.append("Checking LDraw library...") + set_job_progress(job_id, status="processing", progress=5, log="\n".join(job_log)) + + ldraw_manager = LDrawManager() + if not await ldraw_manager.ensure_library_exists(): + log_str = "\n".join(job_log) + set_job_progress(job_id, status="failed", error_message="Failed to download LDraw library", log=log_str) + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "failed" + job.error_message = "Failed to download LDraw library" + job.log = log_str + db.commit() + remove_job_progress(job_id) + return + if _exit_if_cancelled(): + return + + job_log.append("Using custom parts list...") + set_job_progress(job_id, progress=10, log="\n".join(job_log)) + + if not parts: + log_str = "\n".join(job_log) + set_job_progress(job_id, status="failed", error_message="No parts in custom project", log=log_str) + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "failed" + job.error_message = "No parts in custom project" + job.log = log_str + db.commit() + remove_job_progress(job_id) + return + + # Read per-part and global rotation / scale from job settings + try: + s = json.loads(job.settings) if job.settings else {} + per_part_rotation = s.get("per_part_rotation") or {} + scale_factor = s.get("scale_factor") + if scale_factor is None: + scale_factor = settings.stl_scale_factor + rot_enabled = s.get("rotation_enabled") + if rot_enabled is None: + rot_enabled = settings.rotation_enabled + rx = s.get("rotation_x", settings.rotation_x) + ry = s.get("rotation_y", settings.rotation_y) + rz = s.get("rotation_z", settings.rotation_z) + except Exception: + per_part_rotation = {} + scale_factor = settings.stl_scale_factor + rot_enabled = settings.rotation_enabled + rx, ry, rz = settings.rotation_x, settings.rotation_y, settings.rotation_z + + scale_factor = float(scale_factor) + scale_factor_backend = scale_factor * 10.0 + sync_config_from_db(db) + db.commit() + + converter = STLConverter() + stl_files = [] + converted_count = 0 + total_parts = len(parts) + total_instances = sum(p.get("quantity", 1) for p in parts) + + for part_index, part in enumerate(parts): + if _exit_if_cancelled(): + return + ldraw_id = part.get("ldraw_id") or part.get("part_num") + quantity = part.get("quantity", 1) + + job_log.append(f"Converting part {part_index + 1}/{total_parts}: {ldraw_id}") + + if not ldraw_id: + job_log.append(f"No LDraw ID for part, skipping") + continue + + pr = per_part_rotation.get(ldraw_id) + if pr is not None and isinstance(pr, dict): + use_rot = True + px = float(pr.get("x", 0)) + py = float(pr.get("y", 0)) + pz = float(pr.get("z", 0)) + elif rot_enabled: + use_rot = True + px, py, pz = rx, ry, rz + elif getattr(settings, "default_orientation_match_preview", True): + use_rot = True + px, py, pz = -90.0, 0.0, 0.0 + else: + use_rot = False + px, py, pz = 0.0, 0.0, 0.0 + + stl_path = converter.get_or_convert_stl( + ldraw_id, + bypass_cache=bypass_cache, + scale_factor=scale_factor_backend, + rotation_enabled=use_rot, + rotation_x=px, rotation_y=py, rotation_z=pz, + db=db, + ) + + if stl_path and stl_path.exists(): + color_rgb = part.get("color_rgb") + for _ in range(quantity): + stl_files.append((stl_path, ldraw_id, color_rgb)) + converted_count += 1 + else: + line = f"Failed to convert {ldraw_id} to STL" + logger.warning(line) + job_log.append(line) + + progress = 20 + int((converted_count / total_instances) * 50) if total_instances else 20 + set_job_progress(job_id, progress=min(progress, 70), log="\n".join(job_log)) + db.commit() + + if not stl_files: + log_str = "\n".join(job_log) + set_job_progress(job_id, status="failed", error_message="No parts could be converted to STL", log=log_str) + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "failed" + job.error_message = "No parts could be converted to STL" + job.log = log_str + db.commit() + remove_job_progress(job_id) + return + if _exit_if_cancelled(): + return + + job_log.append("Building output (3MF / ZIP)...") + set_job_progress(job_id, progress=75, log="\n".join(job_log)) + + output_filename = None + need_zip = generate_stl or (generate_3mf and generate_stl) + threemf_path = settings.output_dir / f"{job_id}.3mf" + + if generate_3mf: + if _exit_if_cancelled(): + return + job_log.append("Generating 3MF...") + set_job_progress(job_id, progress=80, log="\n".join(job_log)) + try: + unique_parts = {} + for stl_path, ldraw_id, color_rgb in stl_files: + if stl_path not in unique_parts: + unique_parts[stl_path] = {"ldraw_id": ldraw_id, "quantity": 0, "color_rgb": color_rgb} + unique_parts[stl_path]["quantity"] += 1 + parts_for_3mf = [ + (path, info["ldraw_id"], info["quantity"], info.get("color_rgb")) + for path, info in unique_parts.items() + ] + threemf_gen = ThreeMFGenerator(part_spacing=settings.part_spacing) + if not threemf_gen.generate_3mf(parts_for_3mf, plate_width, plate_depth, plate_height, threemf_path): + raise RuntimeError("3MF generation returned False") + except Exception as e: + logger.error(f"Job {job_id}: 3MF generation error: {e}") + if not generate_stl: + log_str = "\n".join(job_log) + set_job_progress(job_id, status="failed", error_message=str(e), log=log_str) + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "failed" + job.error_message = str(e) + job.log = log_str + db.commit() + remove_job_progress(job_id) + return + generate_3mf = False + + if need_zip: + if _exit_if_cancelled(): + return + num_files = (1 if (generate_3mf and threemf_path.exists()) else 0) + (len(stl_files) if generate_stl else 0) + total_bytes = 0 + if generate_3mf and threemf_path.exists(): + total_bytes += threemf_path.stat().st_size + for stl_path, _, _ in stl_files: + if stl_path.exists(): + total_bytes += stl_path.stat().st_size + size_mb = total_bytes / (1024 * 1024) + size_str = f"{size_mb:.1f} MB" if size_mb >= 0.01 else f"{total_bytes} B" + job_log.append(f"Creating ZIP: {num_files} files ({size_str})...") + set_job_progress(job_id, progress=82, log="\n".join(job_log)) + zip_filename = f"{job_id}.zip" + zip_path = settings.output_dir / zip_filename + try: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: + if generate_3mf and threemf_path.exists(): + zipf.write(threemf_path, f"{job_id}.3mf") + if generate_stl: + part_counts = {} + for stl_path, ldraw_id, _ in stl_files: + if ldraw_id not in part_counts: + part_counts[ldraw_id] = 0 + part_counts[ldraw_id] += 1 + zip_name = f"stls/{ldraw_id}_{part_counts[ldraw_id]}.stl" + zipf.write(stl_path, zip_name) + output_filename = zip_filename + if generate_3mf and threemf_path.exists(): + try: + threemf_path.unlink() + except OSError: + pass + zip_size = zip_path.stat().st_size + zip_mb = zip_size / (1024 * 1024) + zip_size_str = f"{zip_mb:.1f} MB" if zip_mb >= 0.01 else f"{zip_size} B" + if total_bytes > 0: + reduction = (1 - zip_size / total_bytes) * 100 + job_log.append(f"ZIP created: {zip_size_str} ({reduction:.0f}% reduction)") + else: + job_log.append(f"ZIP created: {zip_size_str}") + set_job_progress(job_id, progress=90, log="\n".join(job_log)) + except Exception as e: + logger.error(f"Job {job_id}: Failed to create ZIP: {e}") + log_str = "\n".join(job_log) + set_job_progress(job_id, status="failed", error_message=str(e), log=log_str) + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "failed" + job.error_message = str(e) + job.log = log_str + db.commit() + remove_job_progress(job_id) + return + else: + output_filename = f"{job_id}.3mf" + + job_log.append("Finalizing...") + set_job_progress(job_id, progress=95, log="\n".join(job_log)) + + if _exit_if_cancelled(): + return + log_str = "\n".join(job_log) + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "completed" + job.progress = 100 + job.output_file = output_filename + job.log = log_str + db.commit() + set_job_progress(job_id, status="completed", progress=100, log=log_str) + logger.info(f"Job {job_id}: Custom project completed with {output_filename}") + + except Exception as e: + logger.error(f"Job {job_id} (custom) failed: {e}", exc_info=True) + log_str = "\n".join(job_log) + set_job_progress(job_id, status="failed", error_message=str(e), log=log_str) + job = db.query(Job).filter(Job.id == job_id).first() + if job: + job.status = "failed" + job.error_message = str(e) + job.log = log_str + db.commit() + remove_job_progress(job_id) + + finally: + remove_job_progress(job_id) + db.close() + + @router.post("/generate", response_model=JobStatus) async def generate_3mf( request: GenerateRequest, diff --git a/backend/api/routes/ldraw_parts.py b/backend/api/routes/ldraw_parts.py new file mode 100644 index 0000000..59f69c2 --- /dev/null +++ b/backend/api/routes/ldraw_parts.py @@ -0,0 +1,64 @@ +"""LDraw part search and index management API routes.""" +import logging +from typing import List, Optional +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session +from backend.database import get_db, LDrawPartIndex +from backend.auth import get_current_user +from backend.api.integrations.ldraw import build_ldraw_part_index, search_ldraw_part_index + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class LDrawPartResult(BaseModel): + part_num: str + description: Optional[str] = None + + +class LDrawIndexStatus(BaseModel): + indexed_count: int + message: str + + +@router.get("/ldraw-parts/search", response_model=List[LDrawPartResult]) +async def search_ldraw_parts( + q: str = Query(..., min_length=1, description="Search term (part number or description)"), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user), +): + """Search the local LDraw part index by part number or description. + + If the index is empty the search returns an empty list; call POST + /api/ldraw-parts/index to populate it first. + """ + results = search_ldraw_part_index(db, q, limit=limit) + return [LDrawPartResult(**r) for r in results] + + +@router.post("/ldraw-parts/index", response_model=LDrawIndexStatus) +async def rebuild_ldraw_index( + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user), +): + """(Re-)build the LDraw part index by scanning the local parts directory. + + Safe to call repeatedly; clears the existing index before re-indexing. + """ + # Clear existing index + db.query(LDrawPartIndex).delete() + db.commit() + count = build_ldraw_part_index(db) + return LDrawIndexStatus(indexed_count=count, message=f"Indexed {count} parts from local LDraw library") + + +@router.get("/ldraw-parts/index/status", response_model=LDrawIndexStatus) +async def get_ldraw_index_status( + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user), +): + """Return the current number of indexed LDraw parts.""" + count = db.query(LDrawPartIndex).count() + return LDrawIndexStatus(indexed_count=count, message=f"{count} parts in index") diff --git a/backend/api/routes/projects.py b/backend/api/routes/projects.py index a97bf51..ad573a5 100644 --- a/backend/api/routes/projects.py +++ b/backend/api/routes/projects.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel, Field, model_validator from sqlalchemy.orm import Session -from backend.database import get_db, Project, Job +from backend.database import get_db, Project, Job, ProjectPart from backend.auth import get_current_user from backend.config import settings from backend.version import __version__ @@ -16,22 +16,53 @@ logger = logging.getLogger(__name__) router = APIRouter() +# Prefix used as the set_num placeholder in jobs belonging to custom projects +CUSTOM_PROJECT_SET_NUM_PREFIX = "custom:" + class ProjectCreate(BaseModel): - set_num: str + set_num: Optional[str] = None # None for custom projects name: str = Field(..., min_length=1) + is_custom: bool = False + + @model_validator(mode="after") + def set_num_required_for_non_custom(self): + if not self.is_custom and not self.set_num: + raise ValueError("set_num is required for non-custom projects") + return self class ProjectResponse(BaseModel): id: str - set_num: str + set_num: Optional[str] = None name: str set_name: Optional[str] = None image_url: Optional[str] = None + is_custom: bool = False created_at: str existing_project_for_set: Optional[bool] = None # True if another project with same set exists +class ProjectPartCreate(BaseModel): + part_num: str = Field(..., min_length=1) + quantity: int = Field(default=1, ge=1, le=9999) + color: Optional[str] = None + color_rgb: Optional[str] = None + + +class ProjectPartUpdate(BaseModel): + quantity: int = Field(..., ge=1, le=9999) + + +class ProjectPartResponse(BaseModel): + id: str + project_id: str + part_num: str + quantity: int + color: Optional[str] = None + color_rgb: Optional[str] = None + + class JobCreateBody(BaseModel): """Body for creating a job under a project. At least one of generate_3mf or generate_stl must be True.""" plate_width: int = Field(default=220, ge=100, le=2000) @@ -75,6 +106,7 @@ async def list_projects(db: Session = Depends(get_db), name=p.name, set_name=p.set_name, image_url=p.image_url, + is_custom=bool(p.is_custom), created_at=p.created_at.isoformat() if p.created_at else "" ) for p in rows @@ -84,7 +116,30 @@ async def list_projects(db: Session = Depends(get_db), @router.post("/projects", response_model=ProjectResponse) async def create_project(data: ProjectCreate, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)): - """Create a project for a set. Optionally warn if another project references the same set.""" + """Create a project. For set-based projects, provide set_num; for custom projects set is_custom=true.""" + if data.is_custom: + project_id = str(uuid.uuid4()) + project = Project( + id=project_id, + set_num=None, + name=data.name.strip(), + set_name=None, + image_url=None, + is_custom=True, + ) + db.add(project) + db.commit() + db.refresh(project) + return ProjectResponse( + id=project.id, + set_num=project.set_num, + name=project.name, + set_name=project.set_name, + image_url=project.image_url, + is_custom=True, + created_at=project.created_at.isoformat() if project.created_at else "", + ) + # Resolve set display info from cache from backend.core.api_cache import DbApiCache from backend.api.integrations.rebrickable import CACHE_KEY_SET @@ -109,7 +164,8 @@ async def create_project(data: ProjectCreate, db: Session = Depends(get_db), set_num=set_num_with_ver, name=data.name.strip(), set_name=set_name, - image_url=image_url + image_url=image_url, + is_custom=False, ) db.add(project) db.commit() @@ -121,6 +177,7 @@ async def create_project(data: ProjectCreate, db: Session = Depends(get_db), name=project.name, set_name=project.set_name, image_url=project.image_url, + is_custom=False, created_at=project.created_at.isoformat() if project.created_at else "", existing_project_for_set=existing_for_set ) @@ -139,6 +196,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db), name=project.name, set_name=project.set_name, image_url=project.image_url, + is_custom=bool(project.is_custom), created_at=project.created_at.isoformat() if project.created_at else "" ) @@ -162,6 +220,7 @@ async def delete_project(project_id: str, db: Session = Depends(get_db), logger.warning(f"Could not delete job file {job.output_file}: {e}") db.query(Job).filter(Job.project_id == project_id).delete() + db.query(ProjectPart).filter(ProjectPart.project_id == project_id).delete() db.delete(project) db.commit() return {"message": f"Project {project_id} and its jobs/files deleted"} @@ -218,6 +277,12 @@ async def create_project_job( if not project: raise HTTPException(status_code=404, detail="Project not found") + if project.is_custom: + # Custom projects require at least one part + part_count = db.query(ProjectPart).filter(ProjectPart.project_id == project_id).count() + if part_count == 0: + raise HTTPException(status_code=422, detail="Custom project has no parts. Add parts before generating.") + job_id = str(uuid.uuid4()) if not claim_job_slot(job_id): raise HTTPException( @@ -233,15 +298,19 @@ async def create_project_job( "generate_3mf": body.generate_3mf, "generate_stl": body.generate_stl, "per_part_rotation": body.per_part_rotation or {}, + "is_custom": bool(project.is_custom), } if body.scale_factor is not None: settings_obj["scale_factor"] = float(body.scale_factor) settings_json = json.dumps(settings_obj) + # For custom projects use a placeholder set_num + set_num_for_job = project.set_num or f"{CUSTOM_PROJECT_SET_NUM_PREFIX}{project_id}" + job = Job( id=job_id, project_id=project_id, - set_num=project.set_num, + set_num=set_num_for_job, status="pending", progress=0, plate_width=body.plate_width, @@ -254,16 +323,42 @@ async def create_project_job( db.commit() db.refresh(job) - start_generation( - job_id, - project.set_num, - body.plate_width, - body.plate_depth, - body.plate_height, - body.bypass_cache, - body.generate_3mf, - body.generate_stl, - ) + if project.is_custom: + from backend.api.routes.generate import start_generation_custom + # Gather parts from project_parts table + project_parts = db.query(ProjectPart).filter(ProjectPart.project_id == project_id).all() + parts_data = [ + { + "part_num": pp.part_num, + "ldraw_id": pp.part_num, + "quantity": pp.quantity, + "color": pp.color, + "color_rgb": pp.color_rgb, + "is_spare": False, + } + for pp in project_parts + ] + start_generation_custom( + job_id, + parts_data, + body.plate_width, + body.plate_depth, + body.plate_height, + body.bypass_cache, + body.generate_3mf, + body.generate_stl, + ) + else: + start_generation( + job_id, + project.set_num, + body.plate_width, + body.plate_depth, + body.plate_height, + body.bypass_cache, + body.generate_3mf, + body.generate_stl, + ) return JobResponse( job_id=job.id, @@ -280,3 +375,134 @@ async def create_project_job( db.rollback() release_job_slot(job_id) raise + + +# ── Custom project part management ────────────────────────────────────────── + +@router.get("/projects/{project_id}/parts", response_model=List[ProjectPartResponse]) +async def list_project_parts( + project_id: str, + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user), +): + """List parts added to a custom project.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + parts = db.query(ProjectPart).filter(ProjectPart.project_id == project_id).all() + return [ + ProjectPartResponse( + id=pp.id, + project_id=pp.project_id, + part_num=pp.part_num, + quantity=pp.quantity, + color=pp.color, + color_rgb=pp.color_rgb, + ) + for pp in parts + ] + + +@router.post("/projects/{project_id}/parts", response_model=ProjectPartResponse) +async def add_project_part( + project_id: str, + body: ProjectPartCreate, + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user), +): + """Add a part to a custom project. If the part already exists its quantity is increased.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + if not project.is_custom: + raise HTTPException(status_code=400, detail="Parts can only be added to custom projects") + + part_num = body.part_num.strip().lower() + # Merge if same part_num already present + existing = db.query(ProjectPart).filter( + ProjectPart.project_id == project_id, + ProjectPart.part_num == part_num, + ).first() + if existing: + existing.quantity += body.quantity + db.commit() + db.refresh(existing) + return ProjectPartResponse( + id=existing.id, + project_id=existing.project_id, + part_num=existing.part_num, + quantity=existing.quantity, + color=existing.color, + color_rgb=existing.color_rgb, + ) + + pp = ProjectPart( + id=str(uuid.uuid4()), + project_id=project_id, + part_num=part_num, + quantity=body.quantity, + color=body.color, + color_rgb=body.color_rgb, + ) + db.add(pp) + db.commit() + db.refresh(pp) + return ProjectPartResponse( + id=pp.id, + project_id=pp.project_id, + part_num=pp.part_num, + quantity=pp.quantity, + color=pp.color, + color_rgb=pp.color_rgb, + ) + + +@router.patch("/projects/{project_id}/parts/{part_id}", response_model=ProjectPartResponse) +async def update_project_part( + project_id: str, + part_id: str, + body: ProjectPartUpdate, + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user), +): + """Update the quantity of a part in a custom project.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + pp = db.query(ProjectPart).filter( + ProjectPart.id == part_id, ProjectPart.project_id == project_id + ).first() + if not pp: + raise HTTPException(status_code=404, detail="Part not found in project") + pp.quantity = body.quantity + db.commit() + db.refresh(pp) + return ProjectPartResponse( + id=pp.id, + project_id=pp.project_id, + part_num=pp.part_num, + quantity=pp.quantity, + color=pp.color, + color_rgb=pp.color_rgb, + ) + + +@router.delete("/projects/{project_id}/parts/{part_id}") +async def remove_project_part( + project_id: str, + part_id: str, + db: Session = Depends(get_db), + current_user: str = Depends(get_current_user), +): + """Remove a part from a custom project.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + pp = db.query(ProjectPart).filter( + ProjectPart.id == part_id, ProjectPart.project_id == project_id + ).first() + if not pp: + raise HTTPException(status_code=404, detail="Part not found in project") + db.delete(pp) + db.commit() + return {"message": f"Part {part_id} removed from project {project_id}"} diff --git a/backend/database.py b/backend/database.py index f6b8ead..82a5e59 100644 --- a/backend/database.py +++ b/backend/database.py @@ -12,14 +12,37 @@ class Project(Base): __tablename__ = "projects" id = Column(String, primary_key=True) # UUID - set_num = Column(String, index=True) + set_num = Column(String, index=True, nullable=True) name = Column(String) # user-defined project name set_name = Column(String, nullable=True) # from set data for display image_url = Column(String, nullable=True) + is_custom = Column(Boolean, default=False, nullable=False) # True = custom project (no set) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class ProjectPart(Base): + """A part belonging to a custom project.""" + __tablename__ = "project_parts" + + id = Column(String, primary_key=True) # UUID + project_id = Column(String, ForeignKey("projects.id"), nullable=False, index=True) + part_num = Column(String, nullable=False) # LDraw part number (e.g. "3001") + quantity = Column(Integer, default=1, nullable=False) + color = Column(String, nullable=True) # human-readable color name + color_rgb = Column(String, nullable=True) # hex RGB e.g. "FF5500" + created_at = Column(DateTime, default=datetime.utcnow) + + +class LDrawPartIndex(Base): + """Index of parts available in the local LDraw library.""" + __tablename__ = "ldraw_part_index" + + part_num = Column(String, primary_key=True) # filename stem, e.g. "3001" + description = Column(String, nullable=True) # from first line of .dat file + indexed_at = Column(DateTime, default=datetime.utcnow) + + class Job(Base): """Track 3MF generation jobs.""" __tablename__ = "jobs" diff --git a/backend/main.py b/backend/main.py index 86cafb9..227af16 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,7 +10,7 @@ from fastapi.responses import FileResponse from backend.config import settings from backend.database import init_db -from backend.api.routes import search, generate, download, settings as settings_routes, projects, parts, auth +from backend.api.routes import search, generate, download, settings as settings_routes, projects, parts, auth, ldraw_parts from backend.core.job_progress import broadcast_progress_task from backend.auth import get_current_user from backend.version import __version__ @@ -129,6 +129,7 @@ def task_done_callback(task): app.include_router(settings_routes.router, prefix=settings.api_prefix, tags=["settings"]) app.include_router(projects.router, prefix=settings.api_prefix, tags=["projects"]) app.include_router(parts.router, prefix=settings.api_prefix, tags=["parts"]) +app.include_router(ldraw_parts.router, prefix=settings.api_prefix, tags=["ldraw-parts"]) @app.get(f"{settings.api_prefix}/version") async def get_version(): diff --git a/backend/tests/test_custom_projects.py b/backend/tests/test_custom_projects.py new file mode 100644 index 0000000..0ba0093 --- /dev/null +++ b/backend/tests/test_custom_projects.py @@ -0,0 +1,171 @@ +"""Tests for custom projects: project parts management and LDraw part index.""" +import os +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + + +@pytest.fixture() +def db_session(): + """In-memory SQLite session with the full schema created via Alembic.""" + # Use the conftest-configured DATABASE_PATH (temp dir) so init_db() works + from backend.database import init_db, SessionLocal, Base, engine as default_engine + init_db() + db = SessionLocal() + try: + yield db + finally: + db.close() + + +class TestLDrawPartIndex: + """Tests for LDraw part indexing and search helpers.""" + + def test_parse_dat_description_returns_description(self, tmp_path): + from backend.api.integrations.ldraw import _parse_dat_description + dat = tmp_path / "3001.dat" + dat.write_text("0 Brick 2 x 4\n1 16 0 0 0 1 0 0 0 1 0 0 0 1 stud.dat\n") + assert _parse_dat_description(dat) == "Brick 2 x 4" + + def test_parse_dat_description_skips_blank_lines(self, tmp_path): + from backend.api.integrations.ldraw import _parse_dat_description + dat = tmp_path / "x.dat" + dat.write_text("\n0 A Nice Part\n") + assert _parse_dat_description(dat) == "A Nice Part" + + def test_parse_dat_description_returns_none_on_empty(self, tmp_path): + from backend.api.integrations.ldraw import _parse_dat_description + dat = tmp_path / "empty.dat" + dat.write_text("") + assert _parse_dat_description(dat) is None + + def test_build_and_search_index(self, tmp_path, db_session): + from backend.api.integrations.ldraw import build_ldraw_part_index, search_ldraw_part_index + + # Create a mini parts directory + parts_dir = tmp_path / "parts" + parts_dir.mkdir() + (parts_dir / "3001.dat").write_text("0 Brick 2 x 4\n") + (parts_dir / "3002.dat").write_text("0 Brick 2 x 3\n") + (parts_dir / "3003.dat").write_text("0 Brick 2 x 2\n") + + count = build_ldraw_part_index(db_session, parts_dir=parts_dir) + assert count == 3 + + results = search_ldraw_part_index(db_session, "brick", limit=10) + assert len(results) == 3 + part_nums = {r["part_num"] for r in results} + assert "3001" in part_nums + + def test_search_by_part_num(self, tmp_path, db_session): + from backend.api.integrations.ldraw import build_ldraw_part_index, search_ldraw_part_index + + parts_dir = tmp_path / "parts" + parts_dir.mkdir() + (parts_dir / "3001.dat").write_text("0 Brick 2 x 4\n") + (parts_dir / "99999.dat").write_text("0 Technic Axle 3\n") + + build_ldraw_part_index(db_session, parts_dir=parts_dir) + + results = search_ldraw_part_index(db_session, "3001", limit=10) + assert len(results) == 1 + assert results[0]["part_num"] == "3001" + + def test_search_empty_query_returns_empty(self, db_session): + from backend.api.integrations.ldraw import search_ldraw_part_index + assert search_ldraw_part_index(db_session, "") == [] + + def test_search_no_match(self, tmp_path, db_session): + from backend.api.integrations.ldraw import build_ldraw_part_index, search_ldraw_part_index + + parts_dir = tmp_path / "parts" + parts_dir.mkdir() + (parts_dir / "3001.dat").write_text("0 Brick 2 x 4\n") + build_ldraw_part_index(db_session, parts_dir=parts_dir) + + results = search_ldraw_part_index(db_session, "zzznomatch", limit=10) + assert results == [] + + +class TestCustomProjectCRUD: + """Tests for custom project creation and part management (DB layer).""" + + def test_create_custom_project(self, db_session): + import uuid + from backend.database import Project + + project = Project( + id=str(uuid.uuid4()), + name="My Custom Build", + is_custom=True, + ) + db_session.add(project) + db_session.commit() + db_session.refresh(project) + + assert project.is_custom is True + assert project.set_num is None + + def test_add_and_list_project_parts(self, db_session): + import uuid + from backend.database import Project, ProjectPart + + project = Project( + id=str(uuid.uuid4()), + name="Custom", + is_custom=True, + ) + db_session.add(project) + db_session.commit() + + pp = ProjectPart( + id=str(uuid.uuid4()), + project_id=project.id, + part_num="3001", + quantity=4, + color="Red", + color_rgb="FF0000", + ) + db_session.add(pp) + db_session.commit() + + parts = db_session.query(ProjectPart).filter(ProjectPart.project_id == project.id).all() + assert len(parts) == 1 + assert parts[0].part_num == "3001" + assert parts[0].quantity == 4 + + def test_remove_project_part(self, db_session): + import uuid + from backend.database import Project, ProjectPart + + project = Project(id=str(uuid.uuid4()), name="Custom", is_custom=True) + db_session.add(project) + db_session.commit() + + pp = ProjectPart(id=str(uuid.uuid4()), project_id=project.id, part_num="3002", quantity=2) + db_session.add(pp) + db_session.commit() + part_id = pp.id + + db_session.delete(pp) + db_session.commit() + + result = db_session.query(ProjectPart).filter(ProjectPart.id == part_id).first() + assert result is None + + def test_update_part_quantity(self, db_session): + import uuid + from backend.database import Project, ProjectPart + + project = Project(id=str(uuid.uuid4()), name="Custom", is_custom=True) + db_session.add(project) + db_session.commit() + + pp = ProjectPart(id=str(uuid.uuid4()), project_id=project.id, part_num="3003", quantity=1) + db_session.add(pp) + db_session.commit() + + pp.quantity = 5 + db_session.commit() + db_session.refresh(pp) + assert pp.quantity == 5 diff --git a/frontend/src/pages/ProjectDetailPage.jsx b/frontend/src/pages/ProjectDetailPage.jsx index 5534d72..923cc9d 100644 --- a/frontend/src/pages/ProjectDetailPage.jsx +++ b/frontend/src/pages/ProjectDetailPage.jsx @@ -33,6 +33,24 @@ function ProjectDetailPage() { const [colorRefPage, setColorRefPage] = useState(1) const [deletingJobId, setDeletingJobId] = useState(null) const [cancellingJobId, setCancellingJobId] = useState(null) + // Custom project parts management + const [customParts, setCustomParts] = useState([]) + const [ldrawSearchQuery, setLdrawSearchQuery] = useState('') + const [ldrawSearchResults, setLdrawSearchResults] = useState([]) + const [ldrawSearching, setLdrawSearching] = useState(false) + const [addingPartNum, setAddingPartNum] = useState(null) + const [removingPartId, setRemovingPartId] = useState(null) + const [ldrawIndexCount, setLdrawIndexCount] = useState(null) + const [buildingIndex, setBuildingIndex] = useState(false) + // Rebrickable set import + const [addPartsTab, setAddPartsTab] = useState('ldraw') // 'ldraw' | 'set' + const [setSearchQuery, setSetSearchQuery] = useState('') + const [setSearchResults, setSetSearchResults] = useState([]) + const [setSearching, setSetSearching] = useState(false) + const [selectedSet, setSelectedSet] = useState(null) + const [setPartsData, setSetPartsData] = useState([]) + const [loadingSetParts, setLoadingSetParts] = useState(false) + const [importingAll, setImportingAll] = useState(false) const WIZARD_PARTS_PAGE_SIZE = 5 const PARTS_PAGE_SIZE = 5 const COLOR_REF_PAGE_SIZE = 20 @@ -56,6 +74,12 @@ function ProjectDetailPage() { return n === Math.round(n) ? Number(n).toFixed(1) : String(n) } + const getPartPreviewUrl = (ldrawId, colorRgb, size = 64) => { + let url = `/api/parts/preview/${encodeURIComponent(ldrawId)}?size=${size}` + if (colorRgb) url += `&color=${encodeURIComponent(colorRgb)}` + return url + } + useEffect(() => { fetchProject() fetchVersion() @@ -66,7 +90,11 @@ function ProjectDetailPage() { }, [projectId]) useEffect(() => { - if (project?.set_num) { + if (!project) return + if (project.is_custom) { + fetchCustomParts() + fetchLdrawIndexStatus() + } else if (project.set_num) { apiFetch(`/api/sets/${encodeURIComponent(project.set_num)}/parts`) .then((r) => r.ok ? r.json() : []) .then(setPartsList) @@ -74,7 +102,7 @@ function ProjectDetailPage() { } else { setPartsList([]) } - }, [project?.set_num]) + }, [project?.set_num, project?.is_custom, project?.id]) useEffect(() => { setColorRefPage(1) @@ -277,6 +305,187 @@ apiFetch(`/api/jobs/${jobId}`) } } + const fetchCustomParts = async () => { + try { + const r = await apiFetch(`/api/projects/${projectId}/parts`) + if (r.ok) setCustomParts(await r.json()) + } catch (e) { + console.error(e) + } + } + + const fetchLdrawIndexStatus = async () => { + try { + const r = await apiFetch('/api/ldraw-parts/index/status') + if (r.ok) { + const d = await r.json() + setLdrawIndexCount(d.indexed_count) + } + } catch (e) { + console.error(e) + } + } + + const buildLdrawIndex = async () => { + if (!confirm('This will scan the local LDraw library and index all parts. This may take a moment. Proceed?')) return + setBuildingIndex(true) + try { + const r = await apiFetch('/api/ldraw-parts/index', { method: 'POST' }) + if (r.ok) { + const d = await r.json() + setLdrawIndexCount(d.indexed_count) + } + } catch (e) { + console.error(e) + } finally { + setBuildingIndex(false) + } + } + + const searchLdrawParts = async (q) => { + if (!q || !q.trim()) { setLdrawSearchResults([]); return } + setLdrawSearching(true) + try { + const r = await apiFetch(`/api/ldraw-parts/search?q=${encodeURIComponent(q.trim())}&limit=20`) + if (r.ok) setLdrawSearchResults(await r.json()) + else setLdrawSearchResults([]) + } catch (e) { + setLdrawSearchResults([]) + } finally { + setLdrawSearching(false) + } + } + + const addCustomPart = async (partNum) => { + setAddingPartNum(partNum) + try { + const r = await apiFetch(`/api/projects/${projectId}/parts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ part_num: partNum, quantity: 1 }), + }) + if (r.ok) await fetchCustomParts() + } catch (e) { + console.error(e) + } finally { + setAddingPartNum(null) + } + } + + const removeCustomPart = async (partId) => { + setRemovingPartId(partId) + try { + const r = await apiFetch(`/api/projects/${projectId}/parts/${partId}`, { method: 'DELETE' }) + if (r.ok) await fetchCustomParts() + } catch (e) { + console.error(e) + } finally { + setRemovingPartId(null) + } + } + + const updateCustomPartQty = async (partId, qty) => { + if (qty < 1 || qty > 9999) return + try { + const r = await apiFetch(`/api/projects/${projectId}/parts/${partId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ quantity: qty }), + }) + if (r.ok) { + const updated = await r.json() + setCustomParts(prev => prev.map(p => p.id === partId ? updated : p)) + } + } catch (e) { + console.error(e) + } + } + + const searchRebrickableSets = async (q) => { + if (!q || !q.trim()) { setSetSearchResults([]); return } + setSetSearching(true) + try { + const r = await apiFetch(`/api/search?query=${encodeURIComponent(q.trim())}&page=1&page_size=10`) + if (r.ok) { + const data = await r.json() + setSetSearchResults(data.results || []) + } else { + setSetSearchResults([]) + } + } catch (e) { + setSetSearchResults([]) + } finally { + setSetSearching(false) + } + } + + const selectSetForImport = async (set) => { + setSelectedSet(set) + setSetPartsData([]) + setLoadingSetParts(true) + try { + const r = await apiFetch(`/api/sets/${encodeURIComponent(set.set_num)}/parts`) + if (r.ok) { + const parts = await r.json() + setSetPartsData(parts.filter(p => !p.is_spare)) + } + } catch (e) { + console.error(e) + } finally { + setLoadingSetParts(false) + } + } + + const addSetPart = async (part) => { + const ldrawId = part.ldraw_id || part.part_num + setAddingPartNum(ldrawId) + try { + const r = await apiFetch(`/api/projects/${projectId}/parts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + part_num: ldrawId, + quantity: part.quantity || 1, + color: part.color || null, + color_rgb: part.color_rgb || null, + }), + }) + if (r.ok) await fetchCustomParts() + } catch (e) { + console.error(e) + } finally { + setAddingPartNum(null) + } + } + + const importAllSetParts = async () => { + if (!setPartsData.length) return + setImportingAll(true) + try { + await Promise.all( + setPartsData + .filter(part => part.ldraw_id || part.part_num) + .map(part => + apiFetch(`/api/projects/${projectId}/parts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + part_num: part.ldraw_id || part.part_num, + quantity: part.quantity || 1, + color: part.color || null, + color_rgb: part.color_rgb || null, + }), + }) + ) + ) + await fetchCustomParts() + } catch (e) { + console.error(e) + } finally { + setImportingAll(false) + } + } + const TERMINAL_JOB_STATUSES = ['completed', 'failed', 'cancelled'] const fetchJobs = async () => { @@ -301,10 +510,7 @@ apiFetch(`/api/jobs/${jobId}`) setPreviewRotationByPart({}) setWizardOpen(true) try { - const [settingsRes, partsRes] = await Promise.all([ - apiFetch('/api/settings'), - project?.set_num ? apiFetch(`/api/sets/${encodeURIComponent(project.set_num)}/parts`) : Promise.resolve(null) - ]) + const settingsRes = await apiFetch('/api/settings') if (settingsRes?.ok) { const s = await settingsRes.json() setWizardGlobalSettings(s) @@ -313,14 +519,27 @@ apiFetch(`/api/jobs/${jobId}`) setPlateHeight(s.default_plate_height ?? 250) setScaleFactor(null) // use global default for new job } - if (partsRes?.ok) { - const partsList = await partsRes.json() + if (project?.is_custom) { + // For custom projects use the project's own parts list for per-part rotation const byId = new Map() - partsList.forEach(p => { - const id = p.ldraw_id || p.part_num - if (id && !byId.has(id)) byId.set(id, { ldraw_id: id, part_num: p.part_num, name: p.name, quantity: p.quantity }) + customParts.forEach(p => { + const id = p.part_num + if (id && !byId.has(id)) byId.set(id, { ldraw_id: id, part_num: id, name: null, quantity: p.quantity }) }) setWizardParts(Array.from(byId.values())) + } else if (project?.set_num) { + const partsRes = await apiFetch(`/api/sets/${encodeURIComponent(project.set_num)}/parts`) + if (partsRes?.ok) { + const partsList = await partsRes.json() + const byId = new Map() + partsList.forEach(p => { + const id = p.ldraw_id || p.part_num + if (id && !byId.has(id)) byId.set(id, { ldraw_id: id, part_num: p.part_num, name: p.name, quantity: p.quantity }) + }) + setWizardParts(Array.from(byId.values())) + } else { + setWizardParts([]) + } } else { setWizardParts([]) } @@ -518,15 +737,262 @@ apiFetch(`/api/jobs/${jobId}`)
- {project.image_url && } + {project.image_url + ? + : project.is_custom + ?
🧱
+ : null + }

{project.name}

-

{project.set_num} {project.set_name && ` · ${project.set_name}`}

+ {project.is_custom + ?

Custom project

+ :

{project.set_num} {project.set_name && ` · ${project.set_name}`}

+ }
+ {/* Custom project: parts management */} + {project.is_custom && ( +
+
+

Parts ({customParts.length})

+ {addPartsTab === 'ldraw' && ( +
+ {ldrawIndexCount !== null && ( + {ldrawIndexCount} parts indexed + )} + +
+ )} +
+ + {/* Tab switcher */} +
+ + +
+ + {/* LDraw part search */} + {addPartsTab === 'ldraw' && ( +
+ {ldrawIndexCount === 0 && ( +

+ The LDraw part index is empty. Click "Build index" above to index the local LDraw library before searching. +

+ )} +
+ setLdrawSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && searchLdrawParts(ldrawSearchQuery)} + placeholder="e.g. 3001 or brick 2x4" + className="flex-1 px-3 py-2 bg-dk-1 border border-dk-3 rounded text-dk-5 focus:outline-none focus:border-mint text-sm" + /> + +
+ {ldrawSearchResults.length > 0 && ( +
+ {ldrawSearchResults.map((r) => ( +
+
+ {r.part_num} + {r.description && {r.description}} +
+ +
+ ))} +
+ )} +
+ )} + + {/* Import from Rebrickable set */} + {addPartsTab === 'set' && ( +
+ {!selectedSet ? ( + <> +
+ setSetSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && searchRebrickableSets(setSearchQuery)} + placeholder="e.g. 75192 or Millennium Falcon" + className="flex-1 px-3 py-2 bg-dk-1 border border-dk-3 rounded text-dk-5 focus:outline-none focus:border-mint text-sm" + /> + +
+ {setSearchResults.length > 0 && ( +
+ {setSearchResults.map((s) => ( + + ))} +
+ )} + + ) : ( + <> + {/* Selected set header */} +
+ {selectedSet.image_url && ( + + )} +
+

{selectedSet.name}

+

{selectedSet.set_num}

+
+
+ {setPartsData.length > 0 && ( + + )} + +
+
+ + {/* Parts from the set */} + {loadingSetParts ? ( +

Loading parts…

+ ) : setPartsData.length === 0 ? ( +

No parts found for this set.

+ ) : ( +
+ {setPartsData.map((p, idx) => { + const ldrawId = p.ldraw_id || p.part_num + return ( +
+ { e.target.style.display = 'none' }} + /> +
+ {ldrawId} + {p.name && {p.name}} + {p.color && · {p.color}} +
+ ×{p.quantity} + +
+ ) + })} +
+ )} + + )} +
+ )} + + {/* Current parts list */} + {customParts.length === 0 ? ( + + ) : ( +
+ {customParts.map((p) => ( +
+ { e.target.style.display = 'none' }} + /> + {p.part_num} +
+ + {p.quantity} + +
+ +
+ ))} +
+ )} +
+ )} + {partsList.length > 0 && (
Part list ({partsList.length} parts) @@ -539,7 +1005,7 @@ apiFetch(`/api/jobs/${jobId}`) className: 'w-20', render: (p) => ( { e.target.style.display = 'none' }} diff --git a/frontend/src/pages/ProjectsPage.jsx b/frontend/src/pages/ProjectsPage.jsx index 3982ab9..ba317c7 100644 --- a/frontend/src/pages/ProjectsPage.jsx +++ b/frontend/src/pages/ProjectsPage.jsx @@ -2,11 +2,15 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { apiFetch } from '../api' import { LoadingState, EmptyState } from '../components/ui' +import Modal from '../components/ui/Modal' function ProjectsPage() { const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(true) const [deletingId, setDeletingId] = useState(null) + const [showCustomModal, setShowCustomModal] = useState(false) + const [customName, setCustomName] = useState('') + const [creating, setCreating] = useState(false) const navigate = useNavigate() useEffect(() => { @@ -38,19 +42,53 @@ function ProjectsPage() { } } + const createCustomProject = async (e) => { + e.preventDefault() + if (!customName.trim()) return + setCreating(true) + try { + const r = await apiFetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: customName.trim(), is_custom: true }), + }) + if (r.ok) { + const project = await r.json() + setShowCustomModal(false) + setCustomName('') + navigate(`/projects/${project.id}`) + } + } catch (e) { + console.error(e) + } finally { + setCreating(false) + } + } + if (loading) return return (
- -

Projects

+
+
+ +

Projects

+
+ +
+ {projects.length === 0 ? ( - + ) : (
{projects.map((p) => ( @@ -60,12 +98,19 @@ function ProjectsPage() { onClick={() => navigate(`/projects/${p.id}`)} >
- {p.image_url && ( + {p.image_url ? ( - )} + ) : p.is_custom ? ( +
🧱
+ ) : null}

{p.name}

-

{p.set_num} {p.set_name && ` · ${p.set_name}`}

+

+ {p.is_custom + ? Custom project + : <>{p.set_num}{p.set_name && ` · ${p.set_name}`} + } +

e.stopPropagation()}> @@ -84,8 +129,47 @@ function ProjectsPage() { ))}
)} + + {/* Custom project creation modal */} + { setShowCustomModal(false); setCustomName('') }} title="New Custom Project"> +
+

+ Create a project with self-selected parts from the local LDraw library. + You can search for and add parts after creating the project. +

+
+ + setCustomName(e.target.value)} + placeholder="e.g. My Space Build" + className="w-full px-3 py-2 bg-dk-1 border border-dk-3 rounded text-dk-5 focus:outline-none focus:border-mint" + autoFocus + required + /> +
+
+ + +
+
+
) } export default ProjectsPage +