From cbc1b1207749afcbce63034d5d05b7a097e03143 Mon Sep 17 00:00:00 2001 From: Arunodoy18 Date: Thu, 26 Feb 2026 01:14:14 +0530 Subject: [PATCH] feat: implement manual cluster management system Backend: - Add manual_clusters + manual_cluster_images tables (SQLite, FK cascade) - Service layer with ClusterNotFoundError / ImageNotFoundError exceptions - RESTful endpoints: POST/GET/PATCH/DELETE /clusters, bulk assign/remove images - 18 unit tests covering all CRUD paths and edge cases - Register tables and router in main.py Frontend: - ManualCluster TypeScript types - Axios API functions for all 7 endpoints - Redux slice (manualClustersSlice) with optimistic remove - useManualClusters custom hook (loading/error state, rollback on failure) - ClusterCard component (inline rename, delete) - ImagePickerModal (paginated 40/page, debounced search, multi-select) - ClusterListPage (/clusters) and ClusterDetailPage (/clusters/:id) - My Clusters nav item in sidebar - New CLUSTERS + CLUSTER_DETAIL routes --- backend/app/database/manual_clusters.py | 287 +++++++++++++++ backend/app/routes/manual_clusters.py | 338 ++++++++++++++++++ backend/app/schemas/manual_clusters.py | 129 +++++++ backend/app/utils/manual_cluster_service.py | 181 ++++++++++ backend/main.py | 4 + backend/tests/conftest.py | 2 + backend/tests/test_manual_clusters.py | 302 ++++++++++++++++ frontend/src/api/api-functions/index.ts | 1 + .../src/api/api-functions/manual_clusters.ts | 75 ++++ frontend/src/api/apiEndpoints.ts | 11 + frontend/src/app/store.ts | 2 + .../src/components/Clusters/ClusterCard.tsx | 139 +++++++ .../components/Clusters/ImagePickerModal.tsx | 257 +++++++++++++ .../Navigation/Sidebar/AppSidebar.tsx | 5 +- frontend/src/constants/routes.ts | 2 + frontend/src/features/manualClustersSlice.ts | 100 ++++++ frontend/src/hooks/useManualClusters.ts | 239 +++++++++++++ .../src/pages/Clusters/ClusterDetailPage.tsx | 319 +++++++++++++++++ .../src/pages/Clusters/ClusterListPage.tsx | 172 +++++++++ frontend/src/routes/AppRoutes.tsx | 4 + frontend/src/types/ManualCluster.ts | 34 ++ 21 files changed, 2602 insertions(+), 1 deletion(-) create mode 100644 backend/app/database/manual_clusters.py create mode 100644 backend/app/routes/manual_clusters.py create mode 100644 backend/app/schemas/manual_clusters.py create mode 100644 backend/app/utils/manual_cluster_service.py create mode 100644 backend/tests/test_manual_clusters.py create mode 100644 frontend/src/api/api-functions/manual_clusters.ts create mode 100644 frontend/src/components/Clusters/ClusterCard.tsx create mode 100644 frontend/src/components/Clusters/ImagePickerModal.tsx create mode 100644 frontend/src/features/manualClustersSlice.ts create mode 100644 frontend/src/hooks/useManualClusters.ts create mode 100644 frontend/src/pages/Clusters/ClusterDetailPage.tsx create mode 100644 frontend/src/pages/Clusters/ClusterListPage.tsx create mode 100644 frontend/src/types/ManualCluster.ts diff --git a/backend/app/database/manual_clusters.py b/backend/app/database/manual_clusters.py new file mode 100644 index 000000000..a6e6e1721 --- /dev/null +++ b/backend/app/database/manual_clusters.py @@ -0,0 +1,287 @@ +""" +Database layer for manual cluster management. + +Manual clusters are user-created groupings of images, independent from +AI-generated face clusters. They use a separate table to ensure no +interference with the AI auto-clustering logic. +""" + +from datetime import datetime, timezone +from typing import List, Optional, TypedDict + +from app.database.connection import get_db_connection + + +# --------------------------------------------------------------------------- +# Type definitions +# --------------------------------------------------------------------------- + +ClusterId = str +ImageId = str + + +class ManualClusterRecord(TypedDict): + cluster_id: str + name: str + created_at: str + updated_at: str + is_auto_generated: bool + + +class ClusterImageRecord(TypedDict): + id: int + cluster_id: str + image_id: str + + +# --------------------------------------------------------------------------- +# Table bootstrap (called once at startup) +# --------------------------------------------------------------------------- + + +def db_create_manual_clusters_table() -> None: + """Create manual_clusters and manual_cluster_images tables if not exists.""" + with get_db_connection() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS manual_clusters ( + cluster_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + is_auto_generated INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS manual_cluster_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cluster_id TEXT NOT NULL, + image_id TEXT NOT NULL, + UNIQUE (cluster_id, image_id), + FOREIGN KEY (cluster_id) REFERENCES manual_clusters(cluster_id) + ON DELETE CASCADE, + FOREIGN KEY (image_id) REFERENCES images(id) + ON DELETE CASCADE + ) + """ + ) + + +# --------------------------------------------------------------------------- +# Cluster CRUD +# --------------------------------------------------------------------------- + + +def db_insert_manual_cluster( + cluster_id: str, + name: str, + is_auto_generated: bool = False, +) -> ManualClusterRecord: + """Insert a new manual cluster row and return it.""" + now = _utcnow() + with get_db_connection() as conn: + conn.execute( + """ + INSERT INTO manual_clusters (cluster_id, name, created_at, updated_at, is_auto_generated) + VALUES (?, ?, ?, ?, ?) + """, + (cluster_id, name, now, now, int(is_auto_generated)), + ) + return ManualClusterRecord( + cluster_id=cluster_id, + name=name, + created_at=now, + updated_at=now, + is_auto_generated=is_auto_generated, + ) + + +def db_get_all_manual_clusters() -> List[ManualClusterRecord]: + """Return all manual clusters ordered by creation date descending.""" + with get_db_connection() as conn: + cursor = conn.execute( + """ + SELECT + mc.cluster_id, + mc.name, + mc.created_at, + mc.updated_at, + mc.is_auto_generated, + COUNT(mci.id) AS image_count + FROM manual_clusters mc + LEFT JOIN manual_cluster_images mci ON mc.cluster_id = mci.cluster_id + GROUP BY mc.cluster_id + ORDER BY mc.created_at DESC + """ + ) + rows = cursor.fetchall() + + return [_row_to_cluster(row, include_count=True) for row in rows] + + +def db_get_manual_cluster_by_id(cluster_id: str) -> Optional[ManualClusterRecord]: + """Fetch a single cluster by its primary key. Returns None if not found.""" + with get_db_connection() as conn: + cursor = conn.execute( + """ + SELECT + mc.cluster_id, + mc.name, + mc.created_at, + mc.updated_at, + mc.is_auto_generated, + COUNT(mci.id) AS image_count + FROM manual_clusters mc + LEFT JOIN manual_cluster_images mci ON mc.cluster_id = mci.cluster_id + WHERE mc.cluster_id = ? + GROUP BY mc.cluster_id + """, + (cluster_id,), + ) + row = cursor.fetchone() + + return _row_to_cluster(row, include_count=True) if row else None + + +def db_update_manual_cluster_name(cluster_id: str, name: str) -> bool: + """Rename a cluster. Returns True if a row was actually updated.""" + now = _utcnow() + with get_db_connection() as conn: + cursor = conn.execute( + "UPDATE manual_clusters SET name = ?, updated_at = ? WHERE cluster_id = ?", + (name, now, cluster_id), + ) + return cursor.rowcount > 0 + + +def db_delete_manual_cluster(cluster_id: str) -> bool: + """Delete a cluster (cascades to manual_cluster_images).""" + with get_db_connection() as conn: + cursor = conn.execute( + "DELETE FROM manual_clusters WHERE cluster_id = ?", + (cluster_id,), + ) + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Image–cluster mapping +# --------------------------------------------------------------------------- + + +def db_get_images_in_manual_cluster(cluster_id: str) -> List[dict]: + """ + Return image rows belonging to a cluster, joined with the images table. + Each dict mirrors the Image type in the frontend. + """ + with get_db_connection() as conn: + cursor = conn.execute( + """ + SELECT + i.id, + i.path, + i.thumbnailPath, + i.metadata + FROM manual_cluster_images mci + JOIN images i ON mci.image_id = i.id + WHERE mci.cluster_id = ? + ORDER BY mci.id ASC + """, + (cluster_id,), + ) + rows = cursor.fetchall() + + return [ + { + "id": row[0], + "path": row[1], + "thumbnailPath": row[2], + "metadata": row[3], + } + for row in rows + ] + + +def db_add_images_to_manual_cluster(cluster_id: str, image_ids: List[str]) -> List[str]: + """ + Bulk-assign image_ids to cluster_id. + + Skips already-assigned images (INSERT OR IGNORE). + Returns the list of image_ids that were actually inserted (not already present). + """ + inserted: List[str] = [] + with get_db_connection() as conn: + for image_id in image_ids: + cursor = conn.execute( + """ + INSERT OR IGNORE INTO manual_cluster_images (cluster_id, image_id) + VALUES (?, ?) + """, + (cluster_id, image_id), + ) + if cursor.rowcount > 0: + inserted.append(image_id) + # Bump updated_at on the parent cluster + conn.execute( + "UPDATE manual_clusters SET updated_at = ? WHERE cluster_id = ?", + (_utcnow(), cluster_id), + ) + return inserted + + +def db_remove_image_from_manual_cluster(cluster_id: str, image_id: str) -> bool: + """Remove a single image from a cluster. Returns True if removed.""" + with get_db_connection() as conn: + cursor = conn.execute( + """ + DELETE FROM manual_cluster_images + WHERE cluster_id = ? AND image_id = ? + """, + (cluster_id, image_id), + ) + if cursor.rowcount > 0: + conn.execute( + "UPDATE manual_clusters SET updated_at = ? WHERE cluster_id = ?", + (_utcnow(), cluster_id), + ) + return cursor.rowcount > 0 + + +def db_images_exist(image_ids: List[str]) -> List[str]: + """ + Return the subset of image_ids that actually exist in the images table. + Used to validate bulk assignment requests. + """ + if not image_ids: + return [] + placeholders = ",".join("?" * len(image_ids)) + with get_db_connection() as conn: + cursor = conn.execute( + f"SELECT id FROM images WHERE id IN ({placeholders})", + image_ids, + ) + return [row[0] for row in cursor.fetchall()] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _row_to_cluster(row: tuple, *, include_count: bool = False) -> dict: + result = { + "cluster_id": row[0], + "name": row[1], + "created_at": row[2], + "updated_at": row[3], + "is_auto_generated": bool(row[4]), + } + if include_count: + result["image_count"] = row[5] if len(row) > 5 else 0 + return result diff --git a/backend/app/routes/manual_clusters.py b/backend/app/routes/manual_clusters.py new file mode 100644 index 000000000..440350635 --- /dev/null +++ b/backend/app/routes/manual_clusters.py @@ -0,0 +1,338 @@ +""" +RESTful API endpoints for manual cluster management. + +Routes +------ +POST /clusters/ – Create cluster +GET /clusters/ – List all clusters +GET /clusters/{cluster_id} – Get cluster + images +PATCH /clusters/{cluster_id} – Rename cluster +DELETE /clusters/{cluster_id} – Delete cluster +POST /clusters/{cluster_id}/images – Bulk-assign images +DELETE /clusters/{cluster_id}/images/{image_id} – Remove image +""" + +import logging + +from fastapi import APIRouter, HTTPException, Path, status + +from app.schemas.manual_clusters import ( + AssignImagesRequest, + AssignImagesResponse, + ClusterDetail, + ClusterSummary, + CreateClusterRequest, + CreateClusterResponse, + DeleteClusterResponse, + ErrorResponse, + GetAllClustersResponse, + GetClusterDetailResponse, + RemoveImageResponse, + RenameClusterRequest, + RenameClusterResponse, +) +from app.utils.manual_cluster_service import ( + ClusterNotFoundError, + DuplicateClusterNameError, + ImageNotFoundError, + assign_images, + create_cluster, + delete_cluster, + get_cluster, + list_clusters, + remove_image, + rename_cluster, +) + +logger = logging.getLogger(__name__) +router = APIRouter() + +_ERR_RESPONSES = {code: {"model": ErrorResponse} for code in [400, 404, 409, 500]} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _not_found(cluster_id: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorResponse( + success=False, + error="Cluster Not Found", + message=f"Cluster '{cluster_id}' does not exist.", + ).model_dump(), + ) + + +def _bad_request(msg: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse( + success=False, + error="Bad Request", + message=msg, + ).model_dump(), + ) + + +def _conflict(msg: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=ErrorResponse( + success=False, + error="Conflict", + message=msg, + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# POST /clusters/ – Create +# --------------------------------------------------------------------------- + + +@router.post( + "/", + response_model=CreateClusterResponse, + status_code=status.HTTP_201_CREATED, + responses=_ERR_RESPONSES, + summary="Create a new manual cluster", +) +def create_cluster_endpoint(body: CreateClusterRequest): + """Create a user-defined cluster that is independent of AI face clusters.""" + try: + record = create_cluster(body.name) + return CreateClusterResponse( + success=True, + message=f"Cluster '{record['name']}' created successfully.", + data=ClusterSummary(**record), + ) + except DuplicateClusterNameError as exc: + raise _conflict(str(exc)) + except ValueError as exc: + raise _bad_request(str(exc)) + except Exception as exc: + logger.exception("Unexpected error creating cluster") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal Error", message=str(exc) + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# GET /clusters/ – List +# --------------------------------------------------------------------------- + + +@router.get( + "/", + response_model=GetAllClustersResponse, + responses=_ERR_RESPONSES, + summary="List all manual clusters", +) +def list_clusters_endpoint(): + """Return all manually created clusters with image counts.""" + try: + clusters = list_clusters() + return GetAllClustersResponse( + success=True, + data=[ClusterSummary(**c) for c in clusters], + ) + except Exception as exc: + logger.exception("Unexpected error listing clusters") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal Error", message=str(exc) + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# GET /clusters/{cluster_id} – Detail +# --------------------------------------------------------------------------- + + +@router.get( + "/{cluster_id}", + response_model=GetClusterDetailResponse, + responses=_ERR_RESPONSES, + summary="Get cluster details including images", +) +def get_cluster_endpoint(cluster_id: str = Path(...)): + """Retrieve a cluster and all images assigned to it.""" + try: + detail = get_cluster(cluster_id) + return GetClusterDetailResponse( + success=True, + data=ClusterDetail( + cluster=ClusterSummary(**detail["cluster"]), + images=detail["images"], + image_count=detail["image_count"], + ), + ) + except ClusterNotFoundError: + raise _not_found(cluster_id) + except Exception as exc: + logger.exception("Unexpected error fetching cluster %s", cluster_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal Error", message=str(exc) + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# PATCH /clusters/{cluster_id} – Rename +# --------------------------------------------------------------------------- + + +@router.patch( + "/{cluster_id}", + response_model=RenameClusterResponse, + responses=_ERR_RESPONSES, + summary="Rename a manual cluster", +) +def rename_cluster_endpoint(cluster_id: str, body: RenameClusterRequest): + """Rename an existing manual cluster.""" + try: + updated = rename_cluster(cluster_id, body.name) + return RenameClusterResponse( + success=True, + message=f"Cluster renamed to '{updated['name']}'.", + data=ClusterSummary(**updated), + ) + except ClusterNotFoundError: + raise _not_found(cluster_id) + except ValueError as exc: + raise _bad_request(str(exc)) + except Exception as exc: + logger.exception("Unexpected error renaming cluster %s", cluster_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal Error", message=str(exc) + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# DELETE /clusters/{cluster_id} – Delete cluster +# --------------------------------------------------------------------------- + + +@router.delete( + "/{cluster_id}", + response_model=DeleteClusterResponse, + responses=_ERR_RESPONSES, + summary="Delete a manual cluster", +) +def delete_cluster_endpoint(cluster_id: str = Path(...)): + """Delete a manual cluster and remove all its image assignments.""" + try: + delete_cluster(cluster_id) + return DeleteClusterResponse( + success=True, + message=f"Cluster '{cluster_id}' deleted.", + ) + except ClusterNotFoundError: + raise _not_found(cluster_id) + except Exception as exc: + logger.exception("Unexpected error deleting cluster %s", cluster_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal Error", message=str(exc) + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# POST /clusters/{cluster_id}/images – Bulk assign +# --------------------------------------------------------------------------- + + +@router.post( + "/{cluster_id}/images", + response_model=AssignImagesResponse, + responses=_ERR_RESPONSES, + summary="Bulk-assign images to a cluster", +) +def assign_images_endpoint(cluster_id: str, body: AssignImagesRequest): + """ + Assign one or more images to a manual cluster. + + - Already-assigned images are silently skipped (idempotent). + - Returns HTTP 400 if any image_id does not exist. + """ + try: + result = assign_images(cluster_id, body.image_ids) + return AssignImagesResponse( + success=True, + message=( + f"{result['assigned_count']} image(s) assigned, " + f"{result['skipped_count']} already present." + ), + **result, + ) + except ClusterNotFoundError: + raise _not_found(cluster_id) + except ImageNotFoundError as exc: + raise _bad_request(f"Image(s) not found: {exc.missing}") + except ValueError as exc: + raise _bad_request(str(exc)) + except Exception as exc: + logger.exception("Unexpected error assigning images to cluster %s", cluster_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal Error", message=str(exc) + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# DELETE /clusters/{cluster_id}/images/{image_id} – Remove single image +# --------------------------------------------------------------------------- + + +@router.delete( + "/{cluster_id}/images/{image_id}", + response_model=RemoveImageResponse, + responses=_ERR_RESPONSES, + summary="Remove an image from a cluster", +) +def remove_image_endpoint(cluster_id: str = Path(...), image_id: str = Path(...)): + """Unassign a specific image from a manual cluster.""" + try: + remove_image(cluster_id, image_id) + return RemoveImageResponse( + success=True, + message=f"Image '{image_id}' removed from cluster '{cluster_id}'.", + ) + except ClusterNotFoundError: + raise _not_found(cluster_id) + except ImageNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorResponse( + success=False, + error="Image Not In Cluster", + message=f"Image '{image_id}' is not assigned to cluster '{cluster_id}'.", + ).model_dump(), + ) + except Exception as exc: + logger.exception( + "Unexpected error removing image %s from cluster %s", image_id, cluster_id + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal Error", message=str(exc) + ).model_dump(), + ) diff --git a/backend/app/schemas/manual_clusters.py b/backend/app/schemas/manual_clusters.py new file mode 100644 index 000000000..4d267f9cf --- /dev/null +++ b/backend/app/schemas/manual_clusters.py @@ -0,0 +1,129 @@ +"""Pydantic schemas for the manual cluster endpoints.""" + +from __future__ import annotations + +from typing import Any, List, Optional + +from pydantic import BaseModel, field_validator + + +# --------------------------------------------------------------------------- +# Shared / reusable +# --------------------------------------------------------------------------- + + +class ErrorResponse(BaseModel): + success: bool = False + error: Optional[str] = None + message: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Request bodies +# --------------------------------------------------------------------------- + + +class CreateClusterRequest(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_must_not_be_blank(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Cluster name must not be blank") + return v.strip() + + +class RenameClusterRequest(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_must_not_be_blank(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Cluster name must not be blank") + return v.strip() + + +class AssignImagesRequest(BaseModel): + image_ids: List[str] + + @field_validator("image_ids") + @classmethod + def must_not_be_empty(cls, v: List[str]) -> List[str]: + if not v: + raise ValueError("image_ids must contain at least one entry") + return v + + +# --------------------------------------------------------------------------- +# Response data shapes +# --------------------------------------------------------------------------- + + +class ClusterSummary(BaseModel): + cluster_id: str + name: str + created_at: str + updated_at: str + is_auto_generated: bool + image_count: int = 0 + + +class ImageInCluster(BaseModel): + id: str + path: str + thumbnailPath: Optional[str] = None + metadata: Optional[Any] = None + + +class ClusterDetail(BaseModel): + cluster: ClusterSummary + images: List[ImageInCluster] + image_count: int + + +# --------------------------------------------------------------------------- +# Envelope responses +# --------------------------------------------------------------------------- + + +class CreateClusterResponse(BaseModel): + success: bool + message: Optional[str] = None + data: Optional[ClusterSummary] = None + + +class RenameClusterResponse(BaseModel): + success: bool + message: Optional[str] = None + data: Optional[ClusterSummary] = None + + +class GetAllClustersResponse(BaseModel): + success: bool + message: Optional[str] = None + data: Optional[List[ClusterSummary]] = None + + +class GetClusterDetailResponse(BaseModel): + success: bool + message: Optional[str] = None + data: Optional[ClusterDetail] = None + + +class AssignImagesResponse(BaseModel): + success: bool + message: Optional[str] = None + assigned_count: int = 0 + skipped_count: int = 0 + + +class RemoveImageResponse(BaseModel): + success: bool + message: Optional[str] = None + + +class DeleteClusterResponse(BaseModel): + success: bool + message: Optional[str] = None diff --git a/backend/app/utils/manual_cluster_service.py b/backend/app/utils/manual_cluster_service.py new file mode 100644 index 000000000..33d82fb8f --- /dev/null +++ b/backend/app/utils/manual_cluster_service.py @@ -0,0 +1,181 @@ +""" +Service layer for manual cluster management. + +All business logic lives here; routes only call into this module and handle +HTTP-level concerns (status codes, serialization). +""" + +from __future__ import annotations + +import uuid +from typing import List + +from app.database.manual_clusters import ( + db_add_images_to_manual_cluster, + db_delete_manual_cluster, + db_get_all_manual_clusters, + db_get_images_in_manual_cluster, + db_get_manual_cluster_by_id, + db_images_exist, + db_insert_manual_cluster, + db_remove_image_from_manual_cluster, + db_update_manual_cluster_name, +) +from app.logging.setup_logging import get_logger + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Custom exceptions (translated to HTTP errors in the route layer) +# --------------------------------------------------------------------------- + + +class ClusterNotFoundError(Exception): + pass + + +class ImageNotFoundError(Exception): + def __init__(self, missing: List[str]): + self.missing = missing + super().__init__(f"Images not found: {missing}") + + +class DuplicateClusterNameError(Exception): + pass + + +# --------------------------------------------------------------------------- +# Cluster CRUD +# --------------------------------------------------------------------------- + + +def create_cluster(name: str) -> dict: + """ + Create a new manual cluster. + + Raises: + DuplicateClusterNameError: placeholder – currently names are non-unique; + extend here if you add a UNIQUE constraint on name. + """ + cluster_id = str(uuid.uuid4()) + record = db_insert_manual_cluster(cluster_id, name, is_auto_generated=False) + logger.info("Manual cluster created: id=%s name=%s", cluster_id, name) + return record + + +def list_clusters() -> List[dict]: + """Return all manual clusters with their image counts.""" + return db_get_all_manual_clusters() + + +def get_cluster(cluster_id: str) -> dict: + """ + Return a cluster including its images. + + Raises: + ClusterNotFoundError: if the cluster does not exist. + """ + cluster = db_get_manual_cluster_by_id(cluster_id) + if cluster is None: + raise ClusterNotFoundError(cluster_id) + + images = db_get_images_in_manual_cluster(cluster_id) + return { + "cluster": cluster, + "images": images, + "image_count": len(images), + } + + +def rename_cluster(cluster_id: str, name: str) -> dict: + """ + Rename a cluster. + + Raises: + ClusterNotFoundError: if the cluster does not exist. + """ + # Raise early if cluster missing + _require_cluster(cluster_id) + db_update_manual_cluster_name(cluster_id, name) + updated = db_get_manual_cluster_by_id(cluster_id) + logger.info("Manual cluster renamed: id=%s new_name=%s", cluster_id, name) + return updated # type: ignore[return-value] + + +def delete_cluster(cluster_id: str) -> None: + """ + Delete a cluster and all its image mappings. + + Raises: + ClusterNotFoundError: if the cluster does not exist. + """ + _require_cluster(cluster_id) + db_delete_manual_cluster(cluster_id) + logger.info("Manual cluster deleted: id=%s", cluster_id) + + +# --------------------------------------------------------------------------- +# Image assignment +# --------------------------------------------------------------------------- + + +def assign_images(cluster_id: str, image_ids: List[str]) -> dict: + """ + Bulk-assign images to a cluster. + + - Validates that every image_id exists in the images table. + - Skips already-assigned images (idempotent). + + Returns: + dict with 'assigned_count' and 'skipped_count'. + + Raises: + ClusterNotFoundError: cluster not found. + ImageNotFoundError: one or more image_ids do not exist. + """ + _require_cluster(cluster_id) + + # Validate that all image_ids exist + existing = set(db_images_exist(image_ids)) + missing = [iid for iid in image_ids if iid not in existing] + if missing: + raise ImageNotFoundError(missing) + + inserted = db_add_images_to_manual_cluster(cluster_id, image_ids) + skipped = len(image_ids) - len(inserted) + logger.info( + "Assigned images to cluster %s: assigned=%d skipped=%d", + cluster_id, + len(inserted), + skipped, + ) + return {"assigned_count": len(inserted), "skipped_count": skipped} + + +def remove_image(cluster_id: str, image_id: str) -> None: + """ + Remove a single image from a cluster. + + Raises: + ClusterNotFoundError: cluster not found. + ImageNotFoundError: image is not assigned to this cluster. + """ + _require_cluster(cluster_id) + removed = db_remove_image_from_manual_cluster(cluster_id, image_id) + if not removed: + raise ImageNotFoundError([image_id]) + logger.info("Removed image %s from cluster %s", image_id, cluster_id) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _require_cluster(cluster_id: str) -> dict: + """Raise ClusterNotFoundError if the cluster does not exist.""" + cluster = db_get_manual_cluster_by_id(cluster_id) + if cluster is None: + raise ClusterNotFoundError(cluster_id) + return cluster diff --git a/backend/main.py b/backend/main.py index 960acdac1..cb95d1a88 100644 --- a/backend/main.py +++ b/backend/main.py @@ -15,6 +15,7 @@ from app.database.faces import db_create_faces_table from app.database.images import db_create_images_table from app.database.face_clusters import db_create_clusters_table +from app.database.manual_clusters import db_create_manual_clusters_table from app.database.yolo_mapping import db_create_YOLO_classes_table from app.database.albums import db_create_albums_table from app.database.albums import db_create_album_images_table @@ -25,6 +26,7 @@ from app.routes.albums import router as albums_router from app.routes.images import router as images_router from app.routes.face_clusters import router as face_clusters_router +from app.routes.manual_clusters import router as manual_clusters_router from app.routes.user_preferences import router as user_preferences_router from app.routes.memories import router as memories_router from app.routes.shutdown import router as shutdown_router @@ -59,6 +61,7 @@ async def lifespan(app: FastAPI): db_create_albums_table() db_create_album_images_table() db_create_metadata_table() + db_create_manual_clusters_table() # Create ProcessPoolExecutor and attach it to app.state app.state.executor = ProcessPoolExecutor(max_workers=1) @@ -141,6 +144,7 @@ async def root(): app.include_router( memories_router ) # Memories router (prefix already defined in router) +app.include_router(manual_clusters_router, prefix="/clusters", tags=["Manual Clusters"]) app.include_router(shutdown_router, tags=["Shutdown"]) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3b7716121..6740b9a23 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,6 +5,7 @@ from app.database.faces import db_create_faces_table from app.database.images import db_create_images_table from app.database.face_clusters import db_create_clusters_table +from app.database.manual_clusters import db_create_manual_clusters_table from app.database.yolo_mapping import db_create_YOLO_classes_table from app.database.albums import db_create_albums_table, db_create_album_images_table from app.database.folders import db_create_folders_table @@ -29,6 +30,7 @@ def setup_before_all_tests(): db_create_album_images_table() db_create_images_table() db_create_metadata_table() + db_create_manual_clusters_table() print("All database tables created successfully") except Exception as e: print(f"Error creating database tables: {e}") diff --git a/backend/tests/test_manual_clusters.py b/backend/tests/test_manual_clusters.py new file mode 100644 index 000000000..5619f41b1 --- /dev/null +++ b/backend/tests/test_manual_clusters.py @@ -0,0 +1,302 @@ +""" +Unit tests for the manual cluster management system. + +All database calls are mocked so the tests run in isolation without a +real SQLite database. +""" + +import sys +import os +import uuid +from datetime import datetime, timezone +from unittest.mock import patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from app.routes import manual_clusters as mc_router + +# --------------------------------------------------------------------------- +# Test app +# --------------------------------------------------------------------------- + +app = FastAPI() +app.include_router(mc_router.router, prefix="/clusters", tags=["Manual Clusters"]) +client = TestClient(app) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NOW = datetime.now(timezone.utc).isoformat() + + +def _cluster( + cluster_id: str | None = None, + name: str = "Test Cluster", + image_count: int = 0, +) -> dict: + return { + "cluster_id": cluster_id or str(uuid.uuid4()), + "name": name, + "created_at": _NOW, + "updated_at": _NOW, + "is_auto_generated": False, + "image_count": image_count, + } + + +# --------------------------------------------------------------------------- +# POST /clusters/ – Create +# --------------------------------------------------------------------------- + + +class TestCreateCluster: + def test_create_cluster_success(self): + record = _cluster(name="Vacation 2024") + with patch( + "app.routes.manual_clusters.create_cluster", return_value=record + ) as mock_create: + response = client.post("/clusters/", json={"name": "Vacation 2024"}) + + assert response.status_code == 201 + body = response.json() + assert body["success"] is True + assert body["data"]["name"] == "Vacation 2024" + mock_create.assert_called_once_with("Vacation 2024") + + def test_create_cluster_blank_name_rejected(self): + response = client.post("/clusters/", json={"name": " "}) + assert response.status_code == 422 # Pydantic validator fires + + def test_create_cluster_empty_name_rejected(self): + response = client.post("/clusters/", json={"name": ""}) + assert response.status_code == 422 + + def test_create_cluster_missing_name_rejected(self): + response = client.post("/clusters/", json={}) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /clusters/ – List +# --------------------------------------------------------------------------- + + +class TestListClusters: + def test_list_clusters_returns_all(self): + clusters = [_cluster(name="A"), _cluster(name="B")] + with patch("app.routes.manual_clusters.list_clusters", return_value=clusters): + response = client.get("/clusters/") + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert len(body["data"]) == 2 + + def test_list_clusters_empty(self): + with patch("app.routes.manual_clusters.list_clusters", return_value=[]): + response = client.get("/clusters/") + + assert response.status_code == 200 + assert response.json()["data"] == [] + + +# --------------------------------------------------------------------------- +# GET /clusters/{cluster_id} – Detail +# --------------------------------------------------------------------------- + + +class TestGetCluster: + def test_get_existing_cluster(self): + cid = str(uuid.uuid4()) + detail = { + "cluster": _cluster(cluster_id=cid, name="Details"), + "images": [], + "image_count": 0, + } + with patch("app.routes.manual_clusters.get_cluster", return_value=detail): + response = client.get(f"/clusters/{cid}") + + assert response.status_code == 200 + assert response.json()["data"]["cluster"]["cluster_id"] == cid + + def test_get_missing_cluster_returns_404(self): + from app.utils.manual_cluster_service import ClusterNotFoundError + + with patch( + "app.routes.manual_clusters.get_cluster", + side_effect=ClusterNotFoundError("missing-id"), + ): + response = client.get("/clusters/missing-id") + + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# PATCH /clusters/{cluster_id} – Rename +# --------------------------------------------------------------------------- + + +class TestRenameCluster: + def test_rename_success(self): + cid = str(uuid.uuid4()) + updated = _cluster(cluster_id=cid, name="New Name") + with patch("app.routes.manual_clusters.rename_cluster", return_value=updated): + response = client.patch(f"/clusters/{cid}", json={"name": "New Name"}) + + assert response.status_code == 200 + assert response.json()["data"]["name"] == "New Name" + + def test_rename_missing_cluster(self): + from app.utils.manual_cluster_service import ClusterNotFoundError + + with patch( + "app.routes.manual_clusters.rename_cluster", + side_effect=ClusterNotFoundError("x"), + ): + response = client.patch("/clusters/x", json={"name": "X"}) + + assert response.status_code == 404 + + def test_rename_blank_name_rejected(self): + response = client.patch("/clusters/some-id", json={"name": " "}) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# DELETE /clusters/{cluster_id} – Delete cluster +# --------------------------------------------------------------------------- + + +class TestDeleteCluster: + def test_delete_existing_cluster(self): + cid = str(uuid.uuid4()) + with patch("app.routes.manual_clusters.delete_cluster"): + response = client.delete(f"/clusters/{cid}") + + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_delete_missing_cluster(self): + from app.utils.manual_cluster_service import ClusterNotFoundError + + with patch( + "app.routes.manual_clusters.delete_cluster", + side_effect=ClusterNotFoundError("x"), + ): + response = client.delete("/clusters/x") + + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /clusters/{cluster_id}/images – Bulk assign +# --------------------------------------------------------------------------- + + +class TestAssignImages: + def test_bulk_assign_success(self): + cid = str(uuid.uuid4()) + result = {"assigned_count": 3, "skipped_count": 1} + with patch("app.routes.manual_clusters.assign_images", return_value=result): + response = client.post( + f"/clusters/{cid}/images", + json={"image_ids": ["a", "b", "c", "d"]}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["assigned_count"] == 3 + assert body["skipped_count"] == 1 + + def test_assign_missing_cluster_returns_404(self): + from app.utils.manual_cluster_service import ClusterNotFoundError + + with patch( + "app.routes.manual_clusters.assign_images", + side_effect=ClusterNotFoundError("x"), + ): + response = client.post("/clusters/x/images", json={"image_ids": ["img1"]}) + + assert response.status_code == 404 + + def test_assign_invalid_image_ids_returns_400(self): + from app.utils.manual_cluster_service import ImageNotFoundError + + cid = str(uuid.uuid4()) + with patch( + "app.routes.manual_clusters.assign_images", + side_effect=ImageNotFoundError(["bad-id"]), + ): + response = client.post( + f"/clusters/{cid}/images", json={"image_ids": ["bad-id"]} + ) + + assert response.status_code == 400 + + def test_assign_empty_image_ids_rejected(self): + cid = str(uuid.uuid4()) + response = client.post(f"/clusters/{cid}/images", json={"image_ids": []}) + assert response.status_code == 422 + + def test_assign_missing_body_rejected(self): + cid = str(uuid.uuid4()) + response = client.post(f"/clusters/{cid}/images", json={}) + assert response.status_code == 422 + + def test_duplicate_assignment_skipped(self): + """All images are already assigned; assigned_count should be 0.""" + cid = str(uuid.uuid4()) + result = {"assigned_count": 0, "skipped_count": 2} + with patch("app.routes.manual_clusters.assign_images", return_value=result): + response = client.post( + f"/clusters/{cid}/images", + json={"image_ids": ["img1", "img2"]}, + ) + + assert response.status_code == 200 + assert response.json()["assigned_count"] == 0 + assert response.json()["skipped_count"] == 2 + + +# --------------------------------------------------------------------------- +# DELETE /clusters/{cluster_id}/images/{image_id} – Remove image +# --------------------------------------------------------------------------- + + +class TestRemoveImage: + def test_remove_existing_assignment(self): + cid = str(uuid.uuid4()) + iid = "img-1" + with patch("app.routes.manual_clusters.remove_image"): + response = client.delete(f"/clusters/{cid}/images/{iid}") + + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_remove_from_missing_cluster_returns_404(self): + from app.utils.manual_cluster_service import ClusterNotFoundError + + with patch( + "app.routes.manual_clusters.remove_image", + side_effect=ClusterNotFoundError("x"), + ): + response = client.delete("/clusters/x/images/img-1") + + assert response.status_code == 404 + + def test_remove_image_not_in_cluster_returns_404(self): + from app.utils.manual_cluster_service import ImageNotFoundError + + cid = str(uuid.uuid4()) + with patch( + "app.routes.manual_clusters.remove_image", + side_effect=ImageNotFoundError(["img-9"]), + ): + response = client.delete(f"/clusters/{cid}/images/img-9") + + assert response.status_code == 404 diff --git a/frontend/src/api/api-functions/index.ts b/frontend/src/api/api-functions/index.ts index 4e22ef925..344f21c6f 100644 --- a/frontend/src/api/api-functions/index.ts +++ b/frontend/src/api/api-functions/index.ts @@ -5,3 +5,4 @@ export * from './folders'; export * from './user_preferences'; export * from './health'; export * from './memories'; +export * from './manual_clusters'; diff --git a/frontend/src/api/api-functions/manual_clusters.ts b/frontend/src/api/api-functions/manual_clusters.ts new file mode 100644 index 000000000..2a21aae2d --- /dev/null +++ b/frontend/src/api/api-functions/manual_clusters.ts @@ -0,0 +1,75 @@ +import { manualClustersEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; +import { + AssignImagesPayload, + CreateClusterPayload, + RenameClusterPayload, +} from '@/types/ManualCluster'; + +export const fetchAllManualClusters = async (): Promise => { + const response = await apiClient.get( + manualClustersEndpoints.getAll, + ); + return response.data; +}; + +export const fetchManualClusterById = async ( + clusterId: string, +): Promise => { + const response = await apiClient.get( + manualClustersEndpoints.getById(clusterId), + ); + return response.data; +}; + +export const createManualCluster = async ( + payload: CreateClusterPayload, +): Promise => { + const response = await apiClient.post( + manualClustersEndpoints.create, + payload, + ); + return response.data; +}; + +export const renameManualCluster = async ( + clusterId: string, + payload: RenameClusterPayload, +): Promise => { + const response = await apiClient.patch( + manualClustersEndpoints.rename(clusterId), + payload, + ); + return response.data; +}; + +export const deleteManualCluster = async ( + clusterId: string, +): Promise => { + const response = await apiClient.delete( + manualClustersEndpoints.delete(clusterId), + ); + return response.data; +}; + +export const assignImagesToCluster = async ( + clusterId: string, + payload: AssignImagesPayload, +): Promise => { + const response = await apiClient.post( + manualClustersEndpoints.assignImages(clusterId), + payload, + ); + return response.data; +}; + +export const removeImageFromCluster = async ( + clusterId: string, + imageId: string, +): Promise => { + const response = await apiClient.delete( + manualClustersEndpoints.removeImage(clusterId, imageId), + ); + return response.data; +}; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index f0e749197..16b7f4ece 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -37,3 +37,14 @@ export const memoriesEndpoints = { onThisDay: '/api/memories/on-this-day', locations: '/api/memories/locations', }; + +export const manualClustersEndpoints = { + getAll: '/clusters/', + create: '/clusters/', + getById: (clusterId: string) => `/clusters/${clusterId}`, + rename: (clusterId: string) => `/clusters/${clusterId}`, + delete: (clusterId: string) => `/clusters/${clusterId}`, + assignImages: (clusterId: string) => `/clusters/${clusterId}/images`, + removeImage: (clusterId: string, imageId: string) => + `/clusters/${clusterId}/images/${imageId}`, +}; diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 98f69e7a9..e25d83290 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -7,6 +7,7 @@ import faceClustersReducer from '@/features/faceClustersSlice'; import infoDialogReducer from '@/features/infoDialogSlice'; import folderReducer from '@/features/folderSlice'; import memoriesReducer from '@/features/memoriesSlice'; +import manualClustersReducer from '@/features/manualClustersSlice'; export const store = configureStore({ reducer: { @@ -18,6 +19,7 @@ export const store = configureStore({ folders: folderReducer, search: searchReducer, memories: memoriesReducer, + manualClusters: manualClustersReducer, }, }); // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/frontend/src/components/Clusters/ClusterCard.tsx b/frontend/src/components/Clusters/ClusterCard.tsx new file mode 100644 index 000000000..5ab5fabec --- /dev/null +++ b/frontend/src/components/Clusters/ClusterCard.tsx @@ -0,0 +1,139 @@ +import React, { memo, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { Pencil, Trash2, Images, Check, X } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ManualCluster } from '@/types/ManualCluster'; + +interface ClusterCardProps { + cluster: ManualCluster; + onRename: (clusterId: string, newName: string) => Promise; + onDelete: (clusterId: string) => Promise; +} + +export const ClusterCard = memo(function ClusterCard({ + cluster, + onRename, + onDelete, +}: ClusterCardProps) { + const navigate = useNavigate(); + const [editing, setEditing] = useState(false); + const [nameInput, setNameInput] = useState(cluster.name); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + + const handleRenameSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = nameInput.trim(); + if (!trimmed || trimmed === cluster.name) { + setEditing(false); + setNameInput(cluster.name); + return; + } + setSaving(true); + const ok = await onRename(cluster.cluster_id, trimmed); + setSaving(false); + if (ok) setEditing(false); + }; + + const handleDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!window.confirm(`Delete cluster "${cluster.name}"? This cannot be undone.`)) return; + setDeleting(true); + await onDelete(cluster.cluster_id); + setDeleting(false); + }; + + const handleCardClick = () => { + if (!editing) navigate(`/clusters/${cluster.cluster_id}`); + }; + + return ( + + +
+ {editing ? ( +
e.stopPropagation()} + > + setNameInput(e.target.value)} + autoFocus + disabled={saving} + className="h-7 text-sm" + maxLength={80} + /> + + +
+ ) : ( + + {cluster.name} + + )} + + {!editing && ( +
+ + +
+ )} +
+ +
+ + + {cluster.image_count}{' '} + {cluster.image_count === 1 ? 'image' : 'images'} + +
+
+
+ ); +}); diff --git a/frontend/src/components/Clusters/ImagePickerModal.tsx b/frontend/src/components/Clusters/ImagePickerModal.tsx new file mode 100644 index 000000000..f5a4c19a8 --- /dev/null +++ b/frontend/src/components/Clusters/ImagePickerModal.tsx @@ -0,0 +1,257 @@ +/** + * ImagePickerModal + * + * A modal gallery picker that lets the user multi-select images from their + * library and then bulk-assign them to the current cluster. + * + * Performance notes: + * - Images are paginated (PAGE_SIZE per load) + * - Cards are memoized via React.memo + * - Selection state uses a Set and local state only – no Redux involved + */ + +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { CheckCircle2, Circle, Loader2, Search } from 'lucide-react'; +import { Image } from '@/types/Media'; + +const PAGE_SIZE = 40; + +interface ImagePickerModalProps { + open: boolean; + onClose: () => void; + allImages: Image[]; + /** image ids already in the cluster – grey them out */ + assignedIds: Set; + onAssign: (selectedIds: string[]) => Promise; + assigning: boolean; +} + +export function ImagePickerModal({ + open, + onClose, + allImages, + assignedIds, + onAssign, + assigning, +}: ImagePickerModalProps) { + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState>(new Set()); + const [page, setPage] = useState(1); + const searchTimer = useRef | null>(null); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + // Debounce search input + useEffect(() => { + if (searchTimer.current) clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(() => setDebouncedSearch(search), 300); + return () => { + if (searchTimer.current) clearTimeout(searchTimer.current); + }; + }, [search]); + + // Reset on open + useEffect(() => { + if (open) { + setSelected(new Set()); + setSearch(''); + setDebouncedSearch(''); + setPage(1); + } + }, [open]); + + const filtered = useMemo(() => { + const q = debouncedSearch.toLowerCase(); + if (!q) return allImages; + return allImages.filter( + (img) => + img.path.toLowerCase().includes(q) || + img.metadata?.name?.toString().toLowerCase().includes(q), + ); + }, [allImages, debouncedSearch]); + + const paged = useMemo( + () => filtered.slice(0, page * PAGE_SIZE), + [filtered, page], + ); + + const loadMore = useCallback( + () => setPage((p) => p + 1), + [], + ); + + const toggle = useCallback( + (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }, + [], + ); + + const selectAll = useCallback(() => { + setSelected(new Set(filtered.map((i) => i.id))); + }, [filtered]); + + const clearAll = useCallback(() => setSelected(new Set()), []); + + const handleAssign = async () => { + if (selected.size === 0) return; + await onAssign(Array.from(selected)); + }; + + const hasMore = paged.length < filtered.length; + + return ( + !v && onClose()}> + + + Add Images to Cluster + + + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + /> +
+ + +
+ + {/* Grid */} +
+ {paged.length === 0 ? ( +

+ No images found. +

+ ) : ( +
+ {paged.map((img) => ( + + ))} +
+ )} + + {hasMore && ( +
+ +
+ )} +
+ + + + {selected.size} image{selected.size !== 1 ? 's' : ''} selected + + + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Memoized picker card +// --------------------------------------------------------------------------- + +interface PickerImageCardProps { + image: Image; + selected: boolean; + alreadyAssigned: boolean; + onToggle: (id: string) => void; +} + +const PickerImageCard = memo(function PickerImageCard({ + image, + selected, + alreadyAssigned, + onToggle, +}: PickerImageCardProps) { + const src = image.thumbnailPath || image.path; + + return ( +
!alreadyAssigned && onToggle(image.id)} + title={alreadyAssigned ? 'Already in this cluster' : ''} + > + {image.metadata?.name?.toString() +
+ {selected ? ( + + ) : ( + !alreadyAssigned && ( + + ) + )} +
+
+ ); +}); diff --git a/frontend/src/components/Navigation/Sidebar/AppSidebar.tsx b/frontend/src/components/Navigation/Sidebar/AppSidebar.tsx index 3fa8cd3d7..beeea811c 100644 --- a/frontend/src/components/Navigation/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Navigation/Sidebar/AppSidebar.tsx @@ -16,6 +16,7 @@ import { Video, BookImage, ClockFading, + Layers, } from 'lucide-react'; import { useLocation, Link } from 'react-router'; import { ROUTES } from '@/constants/routes'; @@ -42,7 +43,8 @@ export function AppSidebar() { return currentPath === ROUTES.HOME || currentPath === ''; } - return currentPath === menuPath; + // Prefix match for nested routes (e.g. /clusters/detail-id) + return currentPath === menuPath || currentPath.startsWith(menuPath + '/'); }; const menuItems = [ @@ -52,6 +54,7 @@ export function AppSidebar() { { name: 'Videos', path: `/${ROUTES.VIDEOS}`, icon: Video }, { name: 'Albums', path: `/${ROUTES.ALBUMS}`, icon: BookImage }, { name: 'Memories', path: `/${ROUTES.MEMORIES}`, icon: ClockFading }, + { name: 'My Clusters', path: `/${ROUTES.CLUSTERS}`, icon: Layers }, { name: 'Settings', path: `/${ROUTES.SETTINGS}`, icon: Bolt }, ]; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 7b657a6e8..942bc038e 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -10,4 +10,6 @@ export const ROUTES = { MEMORIES: 'memories', MEMORY_DETAIL: 'memories/:memoryId', PERSON: 'person/:clusterId', + CLUSTERS: 'clusters', + CLUSTER_DETAIL: 'clusters/:clusterId', }; diff --git a/frontend/src/features/manualClustersSlice.ts b/frontend/src/features/manualClustersSlice.ts new file mode 100644 index 000000000..5ec895d3d --- /dev/null +++ b/frontend/src/features/manualClustersSlice.ts @@ -0,0 +1,100 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { + ManualCluster, + ManualClusterDetail, + ManualClusterImage, +} from '@/types/ManualCluster'; + +export interface ManualClustersState { + clusters: ManualCluster[]; + /** Fully-loaded detail keyed by cluster_id */ + details: Record; +} + +const initialState: ManualClustersState = { + clusters: [], + details: {}, +}; + +const manualClustersSlice = createSlice({ + name: 'manualClusters', + initialState, + reducers: { + // --- List --- + setClusters(state, action: PayloadAction) { + state.clusters = action.payload; + }, + addCluster(state, action: PayloadAction) { + state.clusters.unshift(action.payload); + }, + updateCluster(state, action: PayloadAction) { + const idx = state.clusters.findIndex( + (c) => c.cluster_id === action.payload.cluster_id, + ); + if (idx !== -1) state.clusters[idx] = action.payload; + // Also update cached detail if present + if (state.details[action.payload.cluster_id]) { + state.details[action.payload.cluster_id].cluster = action.payload; + } + }, + removeCluster(state, action: PayloadAction) { + state.clusters = state.clusters.filter( + (c) => c.cluster_id !== action.payload, + ); + delete state.details[action.payload]; + }, + + // --- Detail --- + setClusterDetail(state, action: PayloadAction) { + state.details[action.payload.cluster.cluster_id] = action.payload; + }, + appendImages( + state, + action: PayloadAction<{ clusterId: string; images: ManualClusterImage[] }>, + ) { + const detail = state.details[action.payload.clusterId]; + if (detail) { + // Deduplicate by id + const existingIds = new Set(detail.images.map((i) => i.id)); + const newImages = action.payload.images.filter( + (i) => !existingIds.has(i.id), + ); + detail.images.push(...newImages); + detail.image_count = detail.images.length; + // Sync count on the summary list too + const summ = state.clusters.find( + (c) => c.cluster_id === action.payload.clusterId, + ); + if (summ) summ.image_count = detail.image_count; + } + }, + removeImage( + state, + action: PayloadAction<{ clusterId: string; imageId: string }>, + ) { + const detail = state.details[action.payload.clusterId]; + if (detail) { + detail.images = detail.images.filter( + (i) => i.id !== action.payload.imageId, + ); + detail.image_count = detail.images.length; + const summ = state.clusters.find( + (c) => c.cluster_id === action.payload.clusterId, + ); + if (summ) summ.image_count = detail.image_count; + } + }, + }, +}); + +export const { + setClusters, + addCluster, + updateCluster, + removeCluster, + setClusterDetail, + appendImages, + removeImage, +} = manualClustersSlice.actions; + +export default manualClustersSlice.reducer; diff --git a/frontend/src/hooks/useManualClusters.ts b/frontend/src/hooks/useManualClusters.ts new file mode 100644 index 000000000..ef7504f37 --- /dev/null +++ b/frontend/src/hooks/useManualClusters.ts @@ -0,0 +1,239 @@ +/** + * useManualClusters + * + * Centralised hook for all manual-cluster operations. + * Keeps the route components thin by containing API calls + Redux dispatch + * in one place. + */ + +import { useState, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '@/app/store'; +import { + setClusters, + addCluster, + updateCluster, + removeCluster, + setClusterDetail, + removeImage, +} from '@/features/manualClustersSlice'; +import { + fetchAllManualClusters, + fetchManualClusterById, + createManualCluster, + renameManualCluster, + deleteManualCluster, + assignImagesToCluster, + removeImageFromCluster, +} from '@/api/api-functions/manual_clusters'; +import { + ManualCluster, + ManualClusterDetail, +} from '@/types/ManualCluster'; + +export function useManualClusters() { + const dispatch = useDispatch(); + const { clusters, details } = useSelector( + (s: RootState) => s.manualClusters, + ); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const clearError = useCallback(() => setError(null), []); + + // ---- List --------------------------------------------------------------- + + const loadClusters = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetchAllManualClusters(); + if (res.success && res.data) { + dispatch(setClusters(res.data as ManualCluster[])); + } else { + setError(res.error ?? 'Failed to load clusters'); + } + } catch { + setError('Failed to load clusters'); + } finally { + setLoading(false); + } + }, [dispatch]); + + // ---- Detail ------------------------------------------------------------- + + const loadClusterDetail = useCallback( + async (clusterId: string) => { + setLoading(true); + setError(null); + try { + const res = await fetchManualClusterById(clusterId); + if (res.success && res.data) { + dispatch(setClusterDetail(res.data as ManualClusterDetail)); + } else { + setError(res.error ?? 'Failed to load cluster'); + } + } catch { + setError('Failed to load cluster'); + } finally { + setLoading(false); + } + }, + [dispatch], + ); + + // ---- Create ------------------------------------------------------------- + + const createCluster = useCallback( + async (name: string): Promise => { + setLoading(true); + setError(null); + try { + const res = await createManualCluster({ name }); + if (res.success && res.data) { + dispatch(addCluster(res.data as ManualCluster)); + return res.data as ManualCluster; + } + setError(res.error ?? 'Failed to create cluster'); + return null; + } catch { + setError('Failed to create cluster'); + return null; + } finally { + setLoading(false); + } + }, + [dispatch], + ); + + // ---- Rename ------------------------------------------------------------- + + const renameCluster = useCallback( + async (clusterId: string, name: string): Promise => { + setLoading(true); + setError(null); + try { + const res = await renameManualCluster(clusterId, { name }); + if (res.success && res.data) { + dispatch(updateCluster(res.data as ManualCluster)); + return true; + } + setError(res.error ?? 'Failed to rename cluster'); + return false; + } catch { + setError('Failed to rename cluster'); + return false; + } finally { + setLoading(false); + } + }, + [dispatch], + ); + + // ---- Delete ------------------------------------------------------------- + + const deleteCluster = useCallback( + async (clusterId: string): Promise => { + setLoading(true); + setError(null); + try { + const res = await deleteManualCluster(clusterId); + if (res.success) { + dispatch(removeCluster(clusterId)); + return true; + } + setError(res.error ?? 'Failed to delete cluster'); + return false; + } catch { + setError('Failed to delete cluster'); + return false; + } finally { + setLoading(false); + } + }, + [dispatch], + ); + + // ---- Assign images ------------------------------------------------------ + + const assignImages = useCallback( + async ( + clusterId: string, + imageIds: string[], + ): Promise<{ assigned: number; skipped: number } | null> => { + setLoading(true); + setError(null); + try { + const res = await assignImagesToCluster(clusterId, { + image_ids: imageIds, + }); + if (res.success) { + // Reload the detail to get the updated image list + const detailRes = await fetchManualClusterById(clusterId); + if (detailRes.success && detailRes.data) { + dispatch(setClusterDetail(detailRes.data as ManualClusterDetail)); + } + return { + assigned: (res as any).assigned_count ?? 0, + skipped: (res as any).skipped_count ?? 0, + }; + } + setError(res.error ?? 'Failed to assign images'); + return null; + } catch { + setError('Failed to assign images'); + return null; + } finally { + setLoading(false); + } + }, + [dispatch], + ); + + // ---- Remove image ------------------------------------------------------- + + const removeImageFromClusters = useCallback( + async (clusterId: string, imageId: string): Promise => { + // Optimistic update + dispatch(removeImage({ clusterId, imageId })); + try { + const res = await removeImageFromCluster(clusterId, imageId); + if (!res.success) { + // Rollback: reload detail + const detailRes = await fetchManualClusterById(clusterId); + if (detailRes.success && detailRes.data) { + dispatch(setClusterDetail(detailRes.data as ManualClusterDetail)); + } + setError(res.error ?? 'Failed to remove image'); + return false; + } + return true; + } catch { + // Rollback + const detailRes = await fetchManualClusterById(clusterId); + if (detailRes.success && detailRes.data) { + dispatch(setClusterDetail(detailRes.data as ManualClusterDetail)); + } + setError('Failed to remove image'); + return false; + } + }, + [dispatch], + ); + + return { + clusters, + details, + loading, + error, + clearError, + loadClusters, + loadClusterDetail, + createCluster, + renameCluster, + deleteCluster, + assignImages, + removeImageFromClusters, + }; +} diff --git a/frontend/src/pages/Clusters/ClusterDetailPage.tsx b/frontend/src/pages/Clusters/ClusterDetailPage.tsx new file mode 100644 index 000000000..cc033c71f --- /dev/null +++ b/frontend/src/pages/Clusters/ClusterDetailPage.tsx @@ -0,0 +1,319 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { + ArrowLeft, + AlertCircle, + Loader2, + Plus, + Trash2, + Pencil, + Check, + X, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ImagePickerModal } from '@/components/Clusters/ImagePickerModal'; +import { useManualClusters } from '@/hooks/useManualClusters'; +import { usePictoQuery } from '@/hooks/useQueryExtension'; +import { fetchAllImages } from '@/api/api-functions'; +import { Image } from '@/types/Media'; +import { ManualClusterImage } from '@/types/ManualCluster'; + +export function ClusterDetailPage() { + const { clusterId } = useParams<{ clusterId: string }>(); + const navigate = useNavigate(); + + const { + details, + loading, + error, + clearError, + loadClusterDetail, + renameCluster, + deleteCluster, + assignImages, + removeImageFromClusters, + } = useManualClusters(); + + const detail = clusterId ? details[clusterId] : undefined; + + // Load cluster detail on mount + useEffect(() => { + if (clusterId) loadClusterDetail(clusterId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clusterId]); + + // Load all images for the picker (only once) + const { data: allImagesData } = usePictoQuery({ + queryKey: ['all-images-for-picker'], + queryFn: () => fetchAllImages(), + }); + const allImages: Image[] = useMemo( + () => (allImagesData?.data as Image[]) ?? [], + [allImagesData], + ); + + // Inline rename state + const [editingName, setEditingName] = useState(false); + const [nameInput, setNameInput] = useState(''); + const [savingName, setSavingName] = useState(false); + + const startEdit = () => { + setNameInput(detail?.cluster.name ?? ''); + setEditingName(true); + }; + + const submitRename = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = nameInput.trim(); + if (!trimmed || !clusterId) { + setEditingName(false); + return; + } + setSavingName(true); + await renameCluster(clusterId, trimmed); + setSavingName(false); + setEditingName(false); + }; + + // Delete cluster + const handleDelete = async () => { + if (!clusterId) return; + if (!window.confirm(`Delete this cluster? This cannot be undone.`)) return; + const ok = await deleteCluster(clusterId); + if (ok) navigate('/clusters'); + }; + + // Image picker modal + const [pickerOpen, setPickerOpen] = useState(false); + const [assigning, setAssigning] = useState(false); + + const assignedIds = useMemo( + () => new Set((detail?.images ?? []).map((i: ManualClusterImage) => i.id)), + [detail], + ); + + const handleAssign = useCallback( + async (selectedIds: string[]) => { + if (!clusterId) return; + setAssigning(true); + await assignImages(clusterId, selectedIds); + setAssigning(false); + setPickerOpen(false); + }, + [assignImages, clusterId], + ); + + const handleRemove = useCallback( + async (imageId: string) => { + if (!clusterId) return; + await removeImageFromClusters(clusterId, imageId); + }, + [removeImageFromClusters, clusterId], + ); + + // ── Render ─────────────────────────────────────────────────────────────── + + if (!detail && loading) { + return ( +
+ +
+ ); + } + + if (!detail && !loading) { + return ( +
+

Cluster not found.

+ +
+ ); + } + + const { cluster, images } = detail!; + + return ( +
+ {/* Top bar */} +
+ + + {/* Cluster name (editable) */} + {editingName ? ( +
+ setNameInput(e.target.value)} + autoFocus + disabled={savingName} + className="h-9 text-xl font-bold" + maxLength={80} + /> + + +
+ ) : ( +

+ {cluster.name} + +

+ )} + +
+ + {cluster.image_count} image{cluster.image_count !== 1 ? 's' : ''} + + + +
+
+ + {/* Error */} + {error && ( + + + + {error} + + + + )} + + {/* Image grid */} + {images.length === 0 ? ( +
+

No images in this cluster

+

+ Click "Add Images" to assign images to this cluster. +

+ +
+ ) : ( +
+ {images.map((img: ManualClusterImage) => ( + + ))} +
+ )} + + {/* Image picker modal */} + setPickerOpen(false)} + allImages={allImages} + assignedIds={assignedIds} + onAssign={handleAssign} + assigning={assigning} + /> +
+ ); +} + +// --------------------------------------------------------------------------- +// Small sub-component: single image in the detail grid +// --------------------------------------------------------------------------- + +interface ClusterImageCardProps { + image: ManualClusterImage; + onRemove: (imageId: string) => void; +} + +function ClusterImageCard({ image, onRemove }: ClusterImageCardProps) { + const [removing, setRemoving] = useState(false); + const src = image.thumbnailPath || image.path; + + const handleRemove = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!window.confirm('Remove this image from the cluster?')) return; + setRemoving(true); + await onRemove(image.id); + setRemoving(false); + }; + + return ( +
+ {image.id} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/Clusters/ClusterListPage.tsx b/frontend/src/pages/Clusters/ClusterListPage.tsx new file mode 100644 index 000000000..2cf8ed9e0 --- /dev/null +++ b/frontend/src/pages/Clusters/ClusterListPage.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react'; +import { Plus, Loader2, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ClusterCard } from '@/components/Clusters/ClusterCard'; +import { useManualClusters } from '@/hooks/useManualClusters'; + +export function ClusterListPage() { + const { + clusters, + loading, + error, + clearError, + loadClusters, + createCluster, + renameCluster, + deleteCluster, + } = useManualClusters(); + + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(''); + const [creating, setCreating] = useState(false); + + useEffect(() => { + loadClusters(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + const name = newName.trim(); + if (!name) return; + setCreating(true); + const result = await createCluster(name); + setCreating(false); + if (result) { + setShowCreate(false); + setNewName(''); + } + }; + + return ( +
+ {/* Header */} +
+
+

My Clusters

+

+ Create and manage custom image groupings, independent from AI + face-detection clusters. +

+
+ +
+ + {/* Error banner */} + {error && ( + + + + {error} + + + + )} + + {/* Content */} + {loading && clusters.length === 0 ? ( +
+ +
+ ) : clusters.length === 0 ? ( +
+

No clusters yet

+

+ Click "Create Cluster" to start organising your images manually. +

+ +
+ ) : ( +
+ {clusters.map((cluster) => ( + + ))} +
+ )} + + {/* Create dialog */} + { + if (!v) { + setShowCreate(false); + setNewName(''); + } + }} + > + + + Create New Cluster + +
+ setNewName(e.target.value)} + autoFocus + maxLength={80} + disabled={creating} + /> + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index 18346baf6..04055fba1 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -11,6 +11,8 @@ import { PersonImages } from '@/pages/PersonImages/PersonImages'; import { ComingSoon } from '@/pages/ComingSoon/ComingSoon'; import { MemoriesPage } from '@/components/Memories'; import { MemoryDetail } from '@/components/Memories/MemoryDetail'; +import { ClusterListPage } from '@/pages/Clusters/ClusterListPage'; +import { ClusterDetailPage } from '@/pages/Clusters/ClusterDetailPage'; export const AppRoutes: React.FC = () => { return ( @@ -26,6 +28,8 @@ export const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> ); diff --git a/frontend/src/types/ManualCluster.ts b/frontend/src/types/ManualCluster.ts new file mode 100644 index 000000000..5cf3195be --- /dev/null +++ b/frontend/src/types/ManualCluster.ts @@ -0,0 +1,34 @@ +export interface ManualCluster { + cluster_id: string; + name: string; + created_at: string; + updated_at: string; + is_auto_generated: boolean; + image_count: number; +} + +export interface ManualClusterImage { + id: string; + path: string; + thumbnailPath?: string | null; + metadata?: Record | null; +} + +export interface ManualClusterDetail { + cluster: ManualCluster; + images: ManualClusterImage[]; + image_count: number; +} + +// Request types +export interface CreateClusterPayload { + name: string; +} + +export interface RenameClusterPayload { + name: string; +} + +export interface AssignImagesPayload { + image_ids: string[]; +}