diff --git a/.gitignore b/.gitignore index bd16f08de..cfd4245da 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,8 @@ videos_cache.txt images_cache.txt videos_cache.txt venv/ +.venv/ frontend/dist + +*.onnx +backend/models/ diff --git a/backend/app/config/pagination.py b/backend/app/config/pagination.py new file mode 100644 index 000000000..fddb4d94b --- /dev/null +++ b/backend/app/config/pagination.py @@ -0,0 +1,9 @@ +"""Pagination configuration constants for the PictoPy backend.""" + +MAX_PAGE_SIZE = 1000 +DEFAULT_PAGE_SIZE = 50 +MAX_OFFSET_VALUE = 1_000_000 +MIN_PAGE_SIZE = 1 + +DEFAULT_RETRY_COUNT = 2 +DEFAULT_RETRY_DELAY_MS = 500 diff --git a/backend/app/database/images.py b/backend/app/database/images.py index ec9541a56..54bb44ebf 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -120,23 +120,61 @@ def db_bulk_insert_images(image_records: List[ImageRecord]) -> bool: conn.close() -def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: +def db_get_all_images( + tagged: Union[bool, None] = None, + limit: Union[int, None] = None, + offset: Union[int, None] = None, +) -> dict: """ - Get all images from the database with their tags. + Retrieve images with tags and optional pagination. + + Uses a CTE (Common Table Expression) to efficiently paginate before joining, + preventing unnecessary data loading and SQL injection risks. Args: - tagged: Optional filter for tagged status. If None, returns all images. - If True, returns only tagged images. If False, returns only untagged images. + tagged: Filter by tagged status (True/False/None for all) + limit: Maximum images to return + offset: Number of images to skip Returns: - List of dictionaries containing all image data including tags + {"images": list of image dicts, "total": total count} """ conn = _connect() cursor = conn.cursor() try: - # Build the query with optional WHERE clause - query = """ + count_query = "SELECT COUNT(*) FROM images" + count_params: List[Union[bool, int]] = [] + + if tagged is not None: + count_query += " WHERE isTagged = ?" + count_params.append(tagged) + + cursor.execute(count_query, count_params) + total_count = cursor.fetchone()[0] + + if total_count == 0: + return {"images": [], "total": 0} + + query_parts = ["WITH paginated_images AS ("] + query_parts.append(" SELECT id FROM images") + query_params: List[Union[bool, int]] = [] + + if tagged is not None: + query_parts.append(" WHERE isTagged = ?") + query_params.append(tagged) + + query_parts.append(" ORDER BY path") + + if limit is not None and limit > 0: + query_parts.append(" LIMIT ?") + query_params.append(limit) + if offset is not None and offset > 0: + query_parts.append(" OFFSET ?") + query_params.append(offset) + + query_parts.append(")") + query_parts.append(""" SELECT i.id, i.path, @@ -147,69 +185,57 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: i.isFavourite, m.name as tag_name FROM images i + INNER JOIN paginated_images pi ON i.id = pi.id LEFT JOIN image_classes ic ON i.id = ic.image_id LEFT JOIN mappings m ON ic.class_id = m.class_id - """ - - params = [] - if tagged is not None: - query += " WHERE i.isTagged = ?" - params.append(tagged) - - query += " ORDER BY i.path, m.name" - - cursor.execute(query, params) - + ORDER BY i.path, m.name + """) + + cursor.execute("\n".join(query_parts), query_params) results = cursor.fetchall() - # Group results by image ID - images_dict = {} - for ( - image_id, - path, - folder_id, - thumbnail_path, - metadata, - is_tagged, - is_favourite, - tag_name, - ) in results: + images_dict: dict[str, dict] = {} + for row in results: + ( + image_id, + path, + folder_id, + thumbnail_path, + metadata, + is_tagged, + is_favourite, + tag_name, + ) = row + if image_id not in images_dict: - # Safely parse metadata JSON -> dict from app.utils.images import image_util_parse_metadata - metadata_dict = image_util_parse_metadata(metadata) - images_dict[image_id] = { "id": image_id, "path": path, "folder_id": str(folder_id), "thumbnailPath": thumbnail_path, - "metadata": metadata_dict, + "metadata": image_util_parse_metadata(metadata), "isTagged": bool(is_tagged), "isFavourite": bool(is_favourite), "tags": [], } - # Add tag if it exists (avoid duplicates) if tag_name and tag_name not in images_dict[image_id]["tags"]: images_dict[image_id]["tags"].append(tag_name) - # Convert to list and set tags to None if empty images = [] for image_data in images_dict.values(): if not image_data["tags"]: image_data["tags"] = None images.append(image_data) - # Sort by path images.sort(key=lambda x: x["path"]) - - return images + return {"images": images, "total": total_count} except Exception as e: - logger.error(f"Error getting all images: {e}") - return [] + logger.error(f"Error getting images: {e}", exc_info=True) + return {"images": [], "total": 0} finally: conn.close() diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index 2e40cd825..28800f0e1 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -2,7 +2,11 @@ from typing import List, Optional from app.database.images import db_get_all_images from app.schemas.images import ErrorResponse -from app.utils.images import image_util_parse_metadata +from app.config.pagination import ( + MAX_PAGE_SIZE, + MAX_OFFSET_VALUE, + MIN_PAGE_SIZE, +) from pydantic import BaseModel from app.database.images import db_toggle_image_favourite_status from app.logging.setup_logging import get_logger @@ -41,29 +45,56 @@ class GetAllImagesResponse(BaseModel): success: bool message: str data: List[ImageData] + total: Optional[int] = None + limit: Optional[int] = None + offset: Optional[int] = None @router.get( "/", response_model=GetAllImagesResponse, - responses={500: {"model": ErrorResponse}}, + responses={ + 400: {"model": ErrorResponse}, + 500: {"model": ErrorResponse}, + }, ) def get_all_images( - tagged: Optional[bool] = Query(None, description="Filter images by tagged status") + tagged: Optional[bool] = Query(None, description="Filter images by tagged status"), + limit: Optional[int] = Query( + None, + description="Number of images per page", + ge=MIN_PAGE_SIZE, + le=MAX_PAGE_SIZE, + ), + offset: Optional[int] = Query(None, description="Number of images to skip", ge=0), ): - """Get all images from the database.""" + """ + Retrieve images with optional filtering and pagination. + + Returns paginated results with total count metadata. + """ try: - # Get all images with tags from database (single query with optional filter) - images = db_get_all_images(tagged=tagged) + if offset is not None and offset > MAX_OFFSET_VALUE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse( + success=False, + error="Invalid offset", + message=f"Offset exceeds maximum allowed value ({MAX_OFFSET_VALUE})", + ).model_dump(), + ) + + result = db_get_all_images(tagged=tagged, limit=limit, offset=offset) + images = result["images"] + total_count = result["total"] - # Convert to response format image_data = [ ImageData( id=image["id"], path=image["path"], folder_id=image["folder_id"], thumbnailPath=image["thumbnailPath"], - metadata=image_util_parse_metadata(image["metadata"]), + metadata=image["metadata"], isTagged=image["isTagged"], isFavourite=image.get("isFavourite", False), tags=image["tags"], @@ -73,10 +104,15 @@ def get_all_images( return GetAllImagesResponse( success=True, - message=f"Successfully retrieved {len(image_data)} images", + message=f"Successfully retrieved {len(image_data)} of {total_count} images", data=image_data, + total=total_count, + limit=limit, + offset=offset, ) + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/backend/main.py b/backend/main.py index 2c1f39e44..c4ca80b2d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -44,14 +44,17 @@ async def lifespan(app: FastAPI): # Create tables and initialize systems generate_openapi_json() - db_create_folders_table() - db_create_images_table() - db_create_YOLO_classes_table() - db_create_clusters_table() # Create clusters table first since faces references it - db_create_faces_table() - db_create_albums_table() - db_create_album_images_table() - db_create_metadata_table() + + # Create tables in the correct order (respecting foreign key dependencies) + db_create_YOLO_classes_table() # No dependencies + db_create_clusters_table() # No dependencies + db_create_folders_table() # No dependencies + db_create_albums_table() # No dependencies + db_create_images_table() # Depends on folders and mappings (YOLO classes) + db_create_faces_table() # Depends on clusters and images + db_create_album_images_table() # Depends on albums and images + db_create_metadata_table() # Depends on images + microservice_util_start_sync_service() # Create ProcessPoolExecutor and attach it to app.state app.state.executor = ProcessPoolExecutor(max_workers=1) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3b7716121..3c1707371 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,13 +22,13 @@ def setup_before_all_tests(): print("Creating database tables...") try: db_create_YOLO_classes_table() - db_create_clusters_table() # Create clusters table first since faces references it - db_create_faces_table() + db_create_clusters_table() db_create_folders_table() db_create_albums_table() - db_create_album_images_table() - db_create_images_table() - db_create_metadata_table() + db_create_images_table() # Must come before faces, album_images, and metadata + db_create_faces_table() # Depends on clusters and images + db_create_album_images_table() # Depends on albums and images + db_create_metadata_table() # Depends on images print("All database tables created successfully") except Exception as e: print(f"Error creating database tables: {e}") diff --git a/backend/tests/test_images.py b/backend/tests/test_images.py new file mode 100644 index 000000000..456344c94 --- /dev/null +++ b/backend/tests/test_images.py @@ -0,0 +1,401 @@ +""" +Comprehensive tests for the images API endpoint with pagination support. +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +import tempfile +import os +import sqlite3 +import importlib + +from app.routes.images import router as images_router + + +@pytest.fixture(scope="function") +def test_db(): + """Create a temporary test database for each test.""" + db_fd, db_path = tempfile.mkstemp() + + import app.config.settings + import app.database.images + import app.database.folders + import app.database.yolo_mapping + + original_db_path = app.config.settings.DATABASE_PATH + app.config.settings.DATABASE_PATH = db_path + + # Reload modules to pick up new DATABASE_PATH + importlib.reload(app.database.yolo_mapping) + importlib.reload(app.database.folders) + importlib.reload(app.database.images) + + from app.database.images import ( + db_create_images_table, + db_bulk_insert_images, + db_insert_image_classes_batch, + ) + from app.database.folders import db_create_folders_table + from app.database.yolo_mapping import db_create_YOLO_classes_table + + # Create tables in correct order (respecting foreign key dependencies) + db_create_YOLO_classes_table() # Creates 'mappings' table + db_create_folders_table() + db_create_images_table() # Depends on folders and mappings + + # Verify tables were created + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cursor.fetchall()] + print(f"Tables created in test DB: {tables}") + conn.close() + + # Store functions in the fixture return value so tests can use them + yield { + "path": db_path, + "db_bulk_insert_images": db_bulk_insert_images, + "db_insert_image_classes_batch": db_insert_image_classes_batch, + } + + app.config.settings.DATABASE_PATH = original_db_path + os.close(db_fd) + os.unlink(db_path) + + +@pytest.fixture +def app(): + """Create FastAPI app instance for testing.""" + app = FastAPI() + app.include_router(images_router, prefix="/images") + return app + + +@pytest.fixture +def client(app): + """Create test client.""" + return TestClient(app) + + +@pytest.fixture +def sample_images_data(test_db): + """Create sample images in the test database.""" + db_path = test_db["path"] + db_bulk_insert_images = test_db["db_bulk_insert_images"] + db_insert_image_classes_batch = test_db["db_insert_image_classes_batch"] + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + folder_id = "test-folder-id" + cursor.execute( + "INSERT OR IGNORE INTO folders (folder_id, folder_path, AI_Tagging) VALUES (?, ?, ?)", + (folder_id, "/test/folder", 0) + ) + conn.commit() + + images = [] + for i in range(100): + images.append({ + "id": f"img_{i:03d}", + "path": f"/test/folder/image_{i:03d}.jpg", + "folder_id": folder_id, + "thumbnailPath": f"/test/folder/thumb_{i:03d}.jpg", + "metadata": '{"name": "test.jpg", "date_created": "2024-01-01", "width": 1920, "height": 1080, "file_size": 1024, "item_type": "image", "file_location": "/test"}', + "isTagged": i % 3 == 0, + }) + + db_bulk_insert_images(images) + + cursor.execute("INSERT OR IGNORE INTO mappings (class_id, name) VALUES (?, ?)", (1, "person")) + cursor.execute("INSERT OR IGNORE INTO mappings (class_id, name) VALUES (?, ?)", (2, "car")) + conn.commit() + + image_class_pairs = [] + for i in range(0, 100, 3): + image_class_pairs.append((f"img_{i:03d}", 1)) + if i % 6 == 0: + image_class_pairs.append((f"img_{i:03d}", 2)) + + db_insert_image_classes_batch(image_class_pairs) + conn.close() + + return {"folder_id": folder_id, "total_images": 100} + + +class TestGetAllImages: + """Test suite for GET /images endpoint.""" + + def test_get_all_images_without_pagination(self, client, sample_images_data): + response = client.get("/images/") + + if response.status_code != 200: + print(f"Error response: {response.text}") + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + assert "data" in data + assert "total" in data + assert data["total"] == 100 + assert len(data["data"]) == 100 + assert data["limit"] is None + assert data["offset"] is None + + def test_get_images_with_limit(self, client, sample_images_data): + response = client.get("/images/?limit=20") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + assert len(data["data"]) == 20 + assert data["total"] == 100 + assert data["limit"] == 20 + assert data["offset"] is None + + def test_get_images_with_limit_and_offset(self, client, sample_images_data): + response = client.get("/images/?limit=25&offset=50") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + assert len(data["data"]) == 25 + assert data["total"] == 100 + assert data["limit"] == 25 + assert data["offset"] == 50 + + def test_pagination_consistency(self, client, sample_images_data): + page1 = client.get("/images/?limit=30&offset=0").json() + page2 = client.get("/images/?limit=30&offset=30").json() + page3 = client.get("/images/?limit=30&offset=60").json() + + ids_page1 = {img["id"] for img in page1["data"]} + ids_page2 = {img["id"] for img in page2["data"]} + ids_page3 = {img["id"] for img in page3["data"]} + + assert len(ids_page1 & ids_page2) == 0 + assert len(ids_page2 & ids_page3) == 0 + assert len(ids_page1 & ids_page3) == 0 + + def test_offset_beyond_total(self, client, sample_images_data): + """Test that offset beyond total returns empty results.""" + response = client.get("/images/?limit=20&offset=200") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + assert len(data["data"]) == 0 + assert data["total"] == 100 + + def test_partial_last_page(self, client, sample_images_data): + """Test fetching last page with partial results.""" + response = client.get("/images/?limit=30&offset=90") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + assert len(data["data"]) == 10 # Only 10 images left + assert data["total"] == 100 + + def test_filter_by_tagged_true(self, client, sample_images_data): + """Test filtering only tagged images.""" + response = client.get("/images/?tagged=true") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + # Every 3rd image is tagged: 0, 3, 6, ..., 99 = 34 images + expected_tagged = len([i for i in range(100) if i % 3 == 0]) + assert data["total"] == expected_tagged + assert len(data["data"]) == expected_tagged + + # Verify all returned images are tagged + for img in data["data"]: + assert img["isTagged"] is True + + def test_filter_by_tagged_false(self, client, sample_images_data): + """Test filtering only untagged images.""" + response = client.get("/images/?tagged=false") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + expected_untagged = len([i for i in range(100) if i % 3 != 0]) + assert data["total"] == expected_untagged + assert len(data["data"]) == expected_untagged + + # Verify all returned images are not tagged + for img in data["data"]: + assert img["isTagged"] is False + + def test_pagination_with_tagged_filter(self, client, sample_images_data): + """Test pagination works correctly with tagged filter.""" + response = client.get("/images/?tagged=true&limit=10&offset=10") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + assert len(data["data"]) == 10 + assert data["limit"] == 10 + assert data["offset"] == 10 + + # Verify all returned images are tagged + for img in data["data"]: + assert img["isTagged"] is True + + def test_images_have_tags(self, client, sample_images_data): + """Test that tagged images include their tags.""" + response = client.get("/images/?tagged=true&limit=5") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + for img in data["data"]: + assert "tags" in img + assert img["tags"] is not None + assert len(img["tags"]) > 0 + assert "person" in img["tags"] + + def test_images_sorted_by_path(self, client, sample_images_data): + """Test that images are sorted by path.""" + response = client.get("/images/?limit=50") + + assert response.status_code == 200 + data = response.json() + + paths = [img["path"] for img in data["data"]] + assert paths == sorted(paths), "Images should be sorted by path" + + def test_invalid_limit_zero(self, client, sample_images_data): + """Test that limit=0 returns validation error.""" + response = client.get("/images/?limit=0") + + assert response.status_code == 422 # Validation error + + def test_invalid_limit_negative(self, client, sample_images_data): + """Test that negative limit returns validation error.""" + response = client.get("/images/?limit=-10") + + assert response.status_code == 422 # Validation error + + def test_invalid_offset_negative(self, client, sample_images_data): + """Test that negative offset returns validation error.""" + response = client.get("/images/?offset=-5") + + assert response.status_code == 422 # Validation error + + def test_limit_exceeds_maximum(self, client, sample_images_data): + """Test that limit exceeding maximum (1000) returns validation error.""" + response = client.get("/images/?limit=1001") + + assert response.status_code == 422 # Validation error + + def test_very_large_offset(self, client, sample_images_data): + """Test that extremely large offset is rejected.""" + response = client.get("/images/?limit=10&offset=9999999") + + assert response.status_code == 400 # Bad request + + def test_empty_database(self, client, test_db): + """Test fetching images from empty database.""" + response = client.get("/images/") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + assert data["total"] == 0 + assert len(data["data"]) == 0 + + def test_response_structure(self, client, sample_images_data): + """Test that response has correct structure.""" + response = client.get("/images/?limit=5") + + assert response.status_code == 200 + data = response.json() + + # Check top-level structure + assert "success" in data + assert "message" in data + assert "data" in data + assert "total" in data + assert "limit" in data + assert "offset" in data + + # Check image structure + if len(data["data"]) > 0: + img = data["data"][0] + assert "id" in img + assert "path" in img + assert "folder_id" in img + assert "thumbnailPath" in img + assert "metadata" in img + assert "isTagged" in img + assert "tags" in img + + # Check metadata structure + metadata = img["metadata"] + assert "name" in metadata + assert "width" in metadata + assert "height" in metadata + assert "file_size" in metadata + assert "item_type" in metadata + + def test_single_image_dataset(self, client, test_db): + """Test pagination with only one image.""" + db_path = test_db["path"] + db_bulk_insert_images = test_db["db_bulk_insert_images"] + + # Use test_db path instead of global DATABASE_PATH + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + folder_id = "single-test-folder" + cursor.execute( + "INSERT INTO folders (folder_id, folder_path, AI_Tagging) VALUES (?, ?, ?)", + (folder_id, "/test", 0) + ) + conn.commit() + conn.close() + + db_bulk_insert_images([{ + "id": "single_img", + "path": "/test/single.jpg", + "folder_id": folder_id, + "thumbnailPath": "/test/thumb.jpg", + "metadata": '{"name": "single.jpg", "date_created": "2024-01-01", "width": 1920, "height": 1080, "file_size": 1024, "item_type": "image", "file_location": "/test"}', + "isTagged": False, + }]) + + response = client.get("/images/?limit=10&offset=0") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + assert data["total"] == 1 + assert len(data["data"]) == 1 + + def test_exact_page_boundary(self, client, sample_images_data): + """Test when total is exactly divisible by limit.""" + # We have 100 images, fetch with limit=50 twice + page1 = client.get("/images/?limit=50&offset=0").json() + page2 = client.get("/images/?limit=50&offset=50").json() + + assert len(page1["data"]) == 50 + assert len(page2["data"]) == 50 + assert page1["total"] == 100 + assert page2["total"] == 100 + + # Verify combined results + all_ids = {img["id"] for img in page1["data"]} | {img["id"] for img in page2["data"]} + assert len(all_ids) == 100 diff --git a/backend/tests/test_metadata_fix.py b/backend/tests/test_metadata_fix.py new file mode 100644 index 000000000..3c1fa5a83 --- /dev/null +++ b/backend/tests/test_metadata_fix.py @@ -0,0 +1,73 @@ +""" +Test to verify metadata is not double-parsed. +This test ensures the fix for double metadata parsing is working correctly. +""" + +import pytest +from fastapi.testclient import TestClient +from app.database.images import db_get_all_images + + +def test_metadata_is_dict_from_database(): + """Test that db_get_all_images returns metadata as dict, not string.""" + result = db_get_all_images(limit=1) + + assert "images" in result + assert "total" in result + + if result["total"] > 0: + first_image = result["images"][0] + assert "metadata" in first_image + + # Critical: metadata should be a dict, not a string + assert isinstance(first_image["metadata"], dict), \ + f"Expected metadata to be dict, got {type(first_image['metadata'])}" + + # Verify it has expected keys + metadata = first_image["metadata"] + assert "name" in metadata + assert "width" in metadata + assert "height" in metadata + + +def test_api_endpoint_returns_valid_metadata(client): + """Test that the /images endpoint returns properly structured metadata.""" + response = client.get("/images/?limit=1") + + assert response.status_code == 200 + data = response.json() + + assert data["success"] is True + + if data["total"] > 0: + first_image = data["data"][0] + + # Metadata should be an object (dict) in JSON response + assert "metadata" in first_image + metadata = first_image["metadata"] + + # Should be a dict with expected structure + assert isinstance(metadata, dict), \ + "Metadata should be a dict in JSON response" + + # Should have standard fields + assert "name" in metadata + assert "width" in metadata + assert "height" in metadata + assert "file_size" in metadata + + # Should NOT be a string that needs parsing + assert not isinstance(metadata, str), \ + "Metadata should not be a JSON string" + + +@pytest.fixture +def client(): + """Create test client for API tests.""" + from fastapi import FastAPI + from app.routes.images import router as images_router + + app = FastAPI() + app.include_router(images_router, prefix="/images") + + return TestClient(app) diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index a29e7c4f1..ce8b1f647 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -831,7 +831,7 @@ "Images" ], "summary": "Get All Images", - "description": "Get all images from the database.", + "description": "Retrieve images with optional filtering and pagination.\n\nReturns paginated results with total count metadata.", "operationId": "get_all_images_images__get", "parameters": [ { @@ -851,6 +851,45 @@ "title": "Tagged" }, "description": "Filter images by tagged status" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 1000, + "minimum": 1 + }, + { + "type": "null" + } + ], + "description": "Number of images per page", + "title": "Limit" + }, + "description": "Number of images per page" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "description": "Number of images to skip", + "title": "Offset" + }, + "description": "Number of images to skip" } ], "responses": { @@ -864,6 +903,16 @@ } } }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + }, + "description": "Bad Request" + }, "500": { "content": { "application/json": { @@ -1855,6 +1904,39 @@ }, "type": "array", "title": "Data" + }, + "total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + }, + "offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Offset" } }, "type": "object", diff --git a/frontend/src/api/api-functions/images.ts b/frontend/src/api/api-functions/images.ts index dda3b21ce..564ff2662 100644 --- a/frontend/src/api/api-functions/images.ts +++ b/frontend/src/api/api-functions/images.ts @@ -1,14 +1,32 @@ import { imagesEndpoints } from '../apiEndpoints'; import { apiClient } from '../axiosConfig'; import { APIResponse } from '@/types/API'; +import { Image } from '@/types/Media'; + +export interface FetchAllImagesParams { + tagged?: boolean; + limit?: number; + offset?: number; +} export const fetchAllImages = async ( - tagged?: boolean, -): Promise => { - const params = tagged !== undefined ? { tagged } : {}; - const response = await apiClient.get( + params?: FetchAllImagesParams, +): Promise> => { + const queryParams: Record = {}; + + if (params?.tagged !== undefined) { + queryParams.tagged = params.tagged; + } + if (params?.limit !== undefined) { + queryParams.limit = params.limit; + } + if (params?.offset !== undefined) { + queryParams.offset = params.offset; + } + + const response = await apiClient.get>( imagesEndpoints.getAllImages, - { params }, + { params: queryParams }, ); return response.data; }; diff --git a/frontend/src/components/Loader/GlobalLoader.tsx b/frontend/src/components/Loader/GlobalLoader.tsx index ccd63eba3..809ae6f1e 100644 --- a/frontend/src/components/Loader/GlobalLoader.tsx +++ b/frontend/src/components/Loader/GlobalLoader.tsx @@ -1,12 +1,43 @@ +import { useEffect, useState, useRef } from 'react'; import { Loader } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { GlobalLoaderProps } from '@/types/loading.ts'; +import { MIN_LOADER_DISPLAY_TIME } from '@/config/pagination'; export const GlobalLoader: React.FC = ({ loading, message, }) => { - return loading ? ( + const [visible, setVisible] = useState(loading); + const timerRef = useRef(null); + const startTimeRef = useRef(null); + + useEffect(() => { + if (loading) { + setVisible(true); + startTimeRef.current = Date.now(); + } else if (startTimeRef.current) { + const elapsed = Date.now() - startTimeRef.current; + + if (elapsed < MIN_LOADER_DISPLAY_TIME) { + timerRef.current = setTimeout(() => { + setVisible(false); + startTimeRef.current = null; + }, MIN_LOADER_DISPLAY_TIME - elapsed); + } else { + setVisible(false); + startTimeRef.current = null; + } + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [loading]); + + return visible ? (
diff --git a/frontend/src/components/OnboardingSteps/AvatarSelectionStep.tsx b/frontend/src/components/OnboardingSteps/AvatarSelectionStep.tsx index 019dd7f43..bee0ca740 100644 --- a/frontend/src/components/OnboardingSteps/AvatarSelectionStep.tsx +++ b/frontend/src/components/OnboardingSteps/AvatarSelectionStep.tsx @@ -32,12 +32,14 @@ export const AvatarSelectionStep: React.FC = ({ const [name, setLocalName] = useState(''); const [selectedAvatar, setLocalAvatar] = useState(''); + const [hasMarkedCompleted, setHasMarkedCompleted] = useState(false); useEffect(() => { - if (localStorage.getItem('name') && localStorage.getItem('avatar')) { + if (!hasMarkedCompleted && localStorage.getItem('name') && localStorage.getItem('avatar')) { + setHasMarkedCompleted(true); dispatch(markCompleted(stepIndex)); } - }, []); + }, [hasMarkedCompleted, dispatch, stepIndex]); const handleAvatarSelect = (avatar: string) => { setLocalAvatar(avatar); diff --git a/frontend/src/components/OnboardingSteps/FolderSetupStep.tsx b/frontend/src/components/OnboardingSteps/FolderSetupStep.tsx index e76c1079a..0de53d37d 100644 --- a/frontend/src/components/OnboardingSteps/FolderSetupStep.tsx +++ b/frontend/src/components/OnboardingSteps/FolderSetupStep.tsx @@ -29,12 +29,14 @@ export function FolderSetupStep({ // Local state for folders const [folder, setFolder] = useState(''); + const [hasMarkedCompleted, setHasMarkedCompleted] = useState(false); useEffect(() => { - if (localStorage.getItem('folderChosen') === 'true') { + if (!hasMarkedCompleted && localStorage.getItem('folderChosen') === 'true') { + setHasMarkedCompleted(true); dispatch(markCompleted(stepIndex)); } - }, []); + }, [hasMarkedCompleted, dispatch, stepIndex]); const { pickSingleFolder, addFolderMutate } = useFolder({ title: 'Select folder to import photos from', diff --git a/frontend/src/components/OnboardingSteps/ServerCheck.tsx b/frontend/src/components/OnboardingSteps/ServerCheck.tsx index 94962caa6..2d0b92233 100644 --- a/frontend/src/components/OnboardingSteps/ServerCheck.tsx +++ b/frontend/src/components/OnboardingSteps/ServerCheck.tsx @@ -65,6 +65,8 @@ export const ServerCheck: React.FC = ({ stepIndex }) => { syncMicroserviceSuccess, syncMicroserviceLoading, syncMicroserviceError, + dispatch, + stepIndex, ]); return null; }; diff --git a/frontend/src/components/OnboardingSteps/ThemeSelectionStep.tsx b/frontend/src/components/OnboardingSteps/ThemeSelectionStep.tsx index 33e8bbd5f..ad39f33b1 100644 --- a/frontend/src/components/OnboardingSteps/ThemeSelectionStep.tsx +++ b/frontend/src/components/OnboardingSteps/ThemeSelectionStep.tsx @@ -29,12 +29,14 @@ export const ThemeSelectionStep: React.FC = ({ }) => { const { setTheme, theme } = useTheme(); const dispatch = useDispatch(); + const [hasMarkedCompleted, setHasMarkedCompleted] = React.useState(false); useEffect(() => { - if (localStorage.getItem('themeChosen')) { + if (!hasMarkedCompleted && localStorage.getItem('themeChosen')) { + setHasMarkedCompleted(true); dispatch(markCompleted(stepIndex)); } - }, []); + }, [hasMarkedCompleted, dispatch, stepIndex]); const handleThemeChange = (value: 'light' | 'dark' | 'system') => { setTheme(value); }; diff --git a/frontend/src/config/pagination.ts b/frontend/src/config/pagination.ts new file mode 100644 index 000000000..c47f3ec20 --- /dev/null +++ b/frontend/src/config/pagination.ts @@ -0,0 +1,11 @@ +/** + * Pagination configuration constants for the PictoPy frontend. + */ + +export const IMAGES_PER_PAGE = 50; +export const SCROLL_THRESHOLD = 0.8; +export const MIN_LOADER_DISPLAY_TIME = 200; +export const DEFAULT_RETRY_COUNT = 2; +export const DEFAULT_RETRY_DELAY = 500; +export const TAGGING_STATUS_POLL_INTERVAL = 1000; +export const DEFAULT_STALE_TIME = 0; diff --git a/frontend/src/hooks/useFolderOperations.tsx b/frontend/src/hooks/useFolderOperations.tsx index 0c0fcc559..a8f84912e 100644 --- a/frontend/src/hooks/useFolderOperations.tsx +++ b/frontend/src/hooks/useFolderOperations.tsx @@ -12,16 +12,15 @@ import { setFolders, setTaggingStatus } from '@/features/folderSlice'; import { FolderDetails } from '@/types/Folder'; import { useMutationFeedback } from './useMutationFeedback'; import { getFoldersTaggingStatus } from '@/api/api-functions/folders'; +import { + DEFAULT_RETRY_COUNT, + TAGGING_STATUS_POLL_INTERVAL, +} from '@/config/pagination'; -/** - * Custom hook for folder operations - * Manages folder queries, AI tagging mutations, and folder deletion - */ export const useFolderOperations = () => { const dispatch = useDispatch(); const folders = useSelector(selectAllFolders); - // Query for folders const foldersQuery = usePictoQuery({ queryKey: ['folders'], queryFn: getAllFolders, @@ -30,16 +29,15 @@ export const useFolderOperations = () => { const taggingStatusQuery = usePictoQuery({ queryKey: ['folders', 'tagging-status'], queryFn: getFoldersTaggingStatus, - staleTime: 1000, - refetchInterval: 1000, + staleTime: TAGGING_STATUS_POLL_INTERVAL, + refetchInterval: TAGGING_STATUS_POLL_INTERVAL, refetchIntervalInBackground: true, enabled: folders.some((f) => f.AI_Tagging), - retry: 2, // Retry failed requests up to 2 times before giving up - retryOnMount: false, // Don't retry on component mount - refetchOnWindowFocus: false, // Don't refetch when window gains focus + retry: DEFAULT_RETRY_COUNT, + retryOnMount: false, + refetchOnWindowFocus: false, }); - // Apply feedback to the folders query useMutationFeedback( { isPending: foldersQuery.isLoading, @@ -56,7 +54,6 @@ export const useFolderOperations = () => { }, ); - // Update Redux store when folders data changes useEffect(() => { if (foldersQuery.data?.data?.folders) { const folders = foldersQuery.data.data.folders as FolderDetails[]; @@ -64,7 +61,6 @@ export const useFolderOperations = () => { } }, [foldersQuery.data, dispatch]); - // Update Redux store with tagging status on each poll useEffect(() => { if (taggingStatusQuery.data?.success) { const raw = taggingStatusQuery.data.data as any; @@ -90,14 +86,25 @@ export const useFolderOperations = () => { taggingStatusQuery.errorMessage, ]); - // Enable AI tagging mutation const enableAITaggingMutation = usePictoMutation({ mutationFn: async (folder_id: string) => enableAITagging({ folder_ids: [folder_id] }), - autoInvalidateTags: ['folders'], + autoInvalidateTags: ['folders', 'images'], + onMutate: async (folder_id: string) => { + const previousFolders = [...folders]; + const updatedFolders = folders.map(f => + f.folder_id === folder_id ? { ...f, AI_Tagging: true } : f + ); + dispatch(setFolders(updatedFolders)); + return { previousFolders }; + }, + onError: (_error, _variables, context) => { + if (context?.previousFolders) { + dispatch(setFolders(context.previousFolders)); + } + }, }); - // Apply feedback to the enable AI tagging mutation useMutationFeedback(enableAITaggingMutation, { showLoading: true, loadingMessage: 'Enabling AI tagging', @@ -107,14 +114,25 @@ export const useFolderOperations = () => { errorMessage: 'Failed to enable AI tagging. Please try again.', }); - // Disable AI tagging mutation const disableAITaggingMutation = usePictoMutation({ mutationFn: async (folder_id: string) => disableAITagging({ folder_ids: [folder_id] }), - autoInvalidateTags: ['folders'], + autoInvalidateTags: ['folders', 'images'], + onMutate: async (folder_id: string) => { + const previousFolders = [...folders]; + const updatedFolders = folders.map(f => + f.folder_id === folder_id ? { ...f, AI_Tagging: false } : f + ); + dispatch(setFolders(updatedFolders)); + return { previousFolders }; + }, + onError: (_error, _variables, context) => { + if (context?.previousFolders) { + dispatch(setFolders(context.previousFolders)); + } + }, }); - // Apply feedback to the disable AI tagging mutation useMutationFeedback(disableAITaggingMutation, { showLoading: true, loadingMessage: 'Disabling AI tagging', @@ -124,14 +142,12 @@ export const useFolderOperations = () => { errorMessage: 'Failed to disable AI tagging. Please try again.', }); - // Delete folder mutation const deleteFolderMutation = usePictoMutation({ mutationFn: async (folder_id: string) => deleteFolders({ folder_ids: [folder_id] }), - autoInvalidateTags: ['folders'], + autoInvalidateTags: ['folders', 'images'], }); - // Apply feedback to the delete folder mutation useMutationFeedback(deleteFolderMutation, { showLoading: true, loadingMessage: 'Deleting folder', @@ -142,9 +158,6 @@ export const useFolderOperations = () => { errorMessage: 'Failed to delete the folder. Please try again.', }); - /** - * Toggle AI tagging for a folder - */ const toggleAITagging = (folder: FolderDetails) => { if (folder.AI_Tagging) { disableAITaggingMutation.mutate(folder.folder_id); @@ -153,23 +166,15 @@ export const useFolderOperations = () => { } }; - /** - * Delete a folder - */ const deleteFolder = (folderId: string) => { deleteFolderMutation.mutate(folderId); }; return { - // Data folders, isLoading: foldersQuery.isLoading, - - // Operations toggleAITagging, deleteFolder, - - // Mutation states (for use in UI, e.g., disabling buttons) enableAITaggingPending: enableAITaggingMutation.isPending, disableAITaggingPending: disableAITaggingMutation.isPending, deleteFolderPending: deleteFolderMutation.isPending, diff --git a/frontend/src/hooks/useQueryExtension.ts b/frontend/src/hooks/useQueryExtension.ts index 3b8846cd2..8a8bce8bc 100644 --- a/frontend/src/hooks/useQueryExtension.ts +++ b/frontend/src/hooks/useQueryExtension.ts @@ -15,6 +15,7 @@ import { } from '@tanstack/react-query'; import { getErrorMessage } from '@/lib/utils'; +import { DEFAULT_RETRY_COUNT, DEFAULT_RETRY_DELAY } from '@/config/pagination'; interface BackendRes { success: boolean; @@ -42,8 +43,8 @@ export function usePictoMutation< const myQueryClient = useQueryClient(); const defaultOptions = { - retry: 2, - retryDelay: 500, + retry: DEFAULT_RETRY_COUNT, + retryDelay: DEFAULT_RETRY_DELAY, }; const res = useMutation( @@ -59,9 +60,12 @@ export function usePictoMutation< onSettled: (data, error, variables, context, mutationContext) => { options.onSettled?.(data, error, variables, context, mutationContext); - if (options.autoInvalidateTags) { - myQueryClient.invalidateQueries({ - queryKey: options.autoInvalidateTags, + if (options.autoInvalidateTags && options.autoInvalidateTags.length > 0) { + options.autoInvalidateTags.forEach((tag) => { + myQueryClient.refetchQueries({ + queryKey: [tag], + type: 'all', + }); }); } }, @@ -90,8 +94,8 @@ export function usePictoQuery< successMessage: string | undefined; } { const defaultOptions = { - retry: 2, - retryDelay: 500, + retry: DEFAULT_RETRY_COUNT, + retryDelay: DEFAULT_RETRY_DELAY, }; const res = useQuery({ diff --git a/frontend/src/pages/AITagging/AITagging.tsx b/frontend/src/pages/AITagging/AITagging.tsx index 187bda3df..f9060f2ef 100644 --- a/frontend/src/pages/AITagging/AITagging.tsx +++ b/frontend/src/pages/AITagging/AITagging.tsx @@ -1,11 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useInfiniteQuery } from '@tanstack/react-query'; import { FaceCollections } from '@/components/FaceCollections'; import { Image } from '@/types/Media'; import { setImages } from '@/features/imageSlice'; import { showLoader, hideLoader } from '@/features/loaderSlice'; -import { selectImages } from '@/features/imageSelectors'; -import { usePictoQuery } from '@/hooks/useQueryExtension'; import { fetchAllImages } from '@/api/api-functions'; import { ChronologicalGallery, @@ -13,33 +12,87 @@ import { } from '@/components/Media/ChronologicalGallery'; import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; import { EmptyAITaggingState } from '@/components/EmptyStates/EmptyAITaggingState'; +import { + IMAGES_PER_PAGE, + SCROLL_THRESHOLD, + DEFAULT_RETRY_COUNT, + DEFAULT_RETRY_DELAY, + DEFAULT_STALE_TIME, +} from '@/config/pagination'; export const AITagging = () => { const dispatch = useDispatch(); const scrollableRef = useRef(null); const [monthMarkers, setMonthMarkers] = useState([]); - const taggedImages = useSelector(selectImages); + const { - data: imagesData, - isLoading: imagesLoading, - isSuccess: imagesSuccess, - isError: imagesError, - } = usePictoQuery({ + data, + isLoading, + isSuccess, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + isRefetching, + } = useInfiniteQuery({ queryKey: ['images', { tagged: true }], - queryFn: () => fetchAllImages(true), + queryFn: ({ pageParam = 0 }) => + fetchAllImages({ tagged: true, limit: IMAGES_PER_PAGE, offset: pageParam }), + getNextPageParam: (lastPage) => { + const total = lastPage?.total ?? 0; + const currentOffset = lastPage?.offset ?? 0; + const limit = lastPage?.limit ?? IMAGES_PER_PAGE; + const nextOffset = currentOffset + limit; + + return nextOffset < total ? nextOffset : undefined; + }, + initialPageParam: 0, + staleTime: DEFAULT_STALE_TIME, + retry: DEFAULT_RETRY_COUNT, + retryDelay: DEFAULT_RETRY_DELAY, }); + const handleScroll = useCallback(() => { + if (!scrollableRef.current || !hasNextPage || isFetchingNextPage) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current; + const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; + + if (scrollPercentage > SCROLL_THRESHOLD) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + useEffect(() => { - if (imagesLoading) { + const scrollElement = scrollableRef.current; + if (!scrollElement) return; + + scrollElement.addEventListener('scroll', handleScroll); + return () => scrollElement.removeEventListener('scroll', handleScroll); + }, [handleScroll]); + + const allImages = useMemo(() => { + if (!data?.pages) return []; + return data.pages.flatMap((page) => (page?.data as Image[]) || []); + }, [data?.pages]); + + useEffect(() => { + if (isSuccess) { + dispatch(setImages(allImages)); + } + }, [allImages, isSuccess, dispatch]); + + const displayImages = allImages; + + useEffect(() => { + const shouldShowLoader = isLoading || (isRefetching && !isFetchingNextPage); + + if (shouldShowLoader) { dispatch(showLoader('Loading AI tagging data')); - } else if (imagesError) { - dispatch(hideLoader()); - } else if (imagesSuccess) { - const images = imagesData?.data as Image[]; - dispatch(setImages(images)); + } else { dispatch(hideLoader()); } - }, [imagesData, imagesSuccess, imagesError, imagesLoading, dispatch]); + }, [isLoading, isFetching, isFetchingNextPage, isRefetching, dispatch]); return (
@@ -49,21 +102,26 @@ export const AITagging = () => { >

AI Tagging

- {/* Face Collections Section */}
- {/* Gallery Section */}
- {taggedImages.length > 0 ? ( - + {displayImages.length > 0 ? ( + <> + + {isFetchingNextPage && ( +
+ Loading more images... +
+ )} + ) : ( )} diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 83c9e5c83..9fdd1ca5f 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,35 +1,98 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { useInfiniteQuery } from '@tanstack/react-query'; import { ChronologicalGallery, MonthMarker, } from '@/components/Media/ChronologicalGallery'; import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; -import { Image } from '@/types/Media'; import { setImages } from '@/features/imageSlice'; import { selectImages } from '@/features/imageSelectors'; -import { usePictoQuery } from '@/hooks/useQueryExtension'; import { fetchAllImages } from '@/api/api-functions'; import { RootState } from '@/app/store'; import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; +import { + IMAGES_PER_PAGE, + SCROLL_THRESHOLD, + DEFAULT_RETRY_COUNT, + DEFAULT_RETRY_DELAY, + DEFAULT_STALE_TIME, +} from '@/config/pagination'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; export const Home = () => { const dispatch = useDispatch(); - const images = useSelector(selectImages); + const reduxImages = useSelector(selectImages); const scrollableRef = useRef(null); const [monthMarkers, setMonthMarkers] = useState([]); const searchState = useSelector((state: RootState) => state.search); const isSearchActive = searchState.active; - const { data, isLoading, isSuccess, isError, error } = usePictoQuery({ + const { + data, + isLoading, + isSuccess, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ queryKey: ['images'], - queryFn: () => fetchAllImages(), + queryFn: ({ pageParam = 0 }) => + fetchAllImages({ limit: IMAGES_PER_PAGE, offset: pageParam }), + getNextPageParam: (lastPage) => { + const total = lastPage?.total ?? 0; + const currentOffset = lastPage?.offset ?? 0; + const limit = lastPage?.limit ?? IMAGES_PER_PAGE; + const nextOffset = currentOffset + limit; + + return nextOffset < total ? nextOffset : undefined; + }, + initialPageParam: 0, enabled: !isSearchActive, + staleTime: DEFAULT_STALE_TIME, + placeholderData: (previousData) => previousData, + retry: DEFAULT_RETRY_COUNT, + retryDelay: DEFAULT_RETRY_DELAY, }); + const handleScroll = useCallback(() => { + if (!scrollableRef.current || !hasNextPage || isFetchingNextPage) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current; + const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; + + if (scrollPercentage > SCROLL_THRESHOLD) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + useEffect(() => { + const scrollElement = scrollableRef.current; + if (!scrollElement) return; + + scrollElement.addEventListener('scroll', handleScroll); + return () => scrollElement.removeEventListener('scroll', handleScroll); + }, [handleScroll]); + + const allImages = useMemo(() => { + if (!data?.pages) return []; + return data.pages.flatMap((page) => page?.data ?? []); + }, [data?.pages]); + + useEffect(() => { + if (!isSearchActive && isSuccess) { + dispatch(setImages(allImages)); + } + }, [allImages, isSuccess, isSearchActive, dispatch]); + + const images = useMemo(() => { + return isSearchActive ? reduxImages : allImages; + }, [isSearchActive, reduxImages, allImages]); + useMutationFeedback( - { isPending: isLoading, isSuccess, isError, error }, + { isPending: isLoading && !isSearchActive, isSuccess, isError, error }, { loadingMessage: 'Loading images', showSuccess: false, @@ -38,39 +101,38 @@ export const Home = () => { }, ); - useEffect(() => { - if (!isSearchActive && isSuccess) { - const images = data?.data as Image[]; - dispatch(setImages(images)); - } - }, [data, isSuccess, dispatch, isSearchActive]); - - const title = - isSearchActive && images.length > 0 + const title = useMemo(() => { + return isSearchActive && images.length > 0 ? `Face Search Results (${images.length} found)` : 'Image Gallery'; + }, [isSearchActive, images.length]); return (
- {/* Gallery Section */}
{images.length > 0 ? ( - + <> + + {isFetchingNextPage && ( +
+ Loading more images... +
+ )} + ) : ( )}
- {/* Timeline Scrollbar */} {monthMarkers.length > 0 && ( { if (currentStepIndex === -1) { navigate(ROUTES.HOME); } - }, [currentStepIndex]); + }, [currentStepIndex, navigate]); return ( diff --git a/frontend/src/types/API.ts b/frontend/src/types/API.ts index d94682128..407491c33 100644 --- a/frontend/src/types/API.ts +++ b/frontend/src/types/API.ts @@ -1,8 +1,9 @@ -export interface APIResponse { - data?: { - [key: string]: any; - }; +export interface APIResponse { + data?: T; success: boolean; error?: string; message?: string; + total?: number; + limit?: number; + offset?: number; }