end of phase 5
This commit is contained in:
parent
53c7382b88
commit
839487567a
@ -8,6 +8,7 @@ from app.api.v1.endpoints import groups
|
|||||||
from app.api.v1.endpoints import invites
|
from app.api.v1.endpoints import invites
|
||||||
from app.api.v1.endpoints import lists
|
from app.api.v1.endpoints import lists
|
||||||
from app.api.v1.endpoints import items
|
from app.api.v1.endpoints import items
|
||||||
|
from app.api.v1.endpoints import ocr
|
||||||
|
|
||||||
api_router_v1 = APIRouter()
|
api_router_v1 = APIRouter()
|
||||||
|
|
||||||
@ -18,5 +19,6 @@ api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"])
|
|||||||
api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"])
|
api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"])
|
||||||
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])
|
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])
|
||||||
api_router_v1.include_router(items.router, tags=["Items"])
|
api_router_v1.include_router(items.router, tags=["Items"])
|
||||||
|
api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
|
||||||
# Add other v1 endpoint routers here later
|
# Add other v1 endpoint routers here later
|
||||||
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
108
be/app/api/v1/endpoints/ocr.py
Normal file
108
be/app/api/v1/endpoints/ocr.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# app/api/v1/endpoints/ocr.py
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from google.api_core import exceptions as google_exceptions # Import Google API exceptions
|
||||||
|
|
||||||
|
from app.api.dependencies import get_current_user
|
||||||
|
from app.models import User as UserModel
|
||||||
|
from app.schemas.ocr import OcrExtractResponse
|
||||||
|
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error # Import helper
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Allowed image MIME types
|
||||||
|
ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||||
|
MAX_FILE_SIZE_MB = 10 # Set a reasonable max file size
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/extract-items",
|
||||||
|
response_model=OcrExtractResponse,
|
||||||
|
summary="Extract List Items via OCR (Gemini)",
|
||||||
|
tags=["OCR"]
|
||||||
|
)
|
||||||
|
async def ocr_extract_items(
|
||||||
|
current_user: UserModel = Depends(get_current_user),
|
||||||
|
# Use File(...) for better metadata handling than UploadFile directly as type hint
|
||||||
|
image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Accepts an image upload, sends it to Gemini Flash with a prompt
|
||||||
|
to extract shopping list items, and returns the parsed items.
|
||||||
|
"""
|
||||||
|
# Check if Gemini client initialized correctly
|
||||||
|
if gemini_initialization_error:
|
||||||
|
logger.error("OCR endpoint called but Gemini client failed to initialize.")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"OCR service unavailable: {gemini_initialization_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.")
|
||||||
|
|
||||||
|
# --- File Validation ---
|
||||||
|
if image_file.content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simple size check (FastAPI/Starlette might handle larger limits via config)
|
||||||
|
# Read content first to get size accurately
|
||||||
|
contents = await image_file.read()
|
||||||
|
if len(contents) > MAX_FILE_SIZE_MB * 1024 * 1024:
|
||||||
|
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB} MB.",
|
||||||
|
)
|
||||||
|
# --- End File Validation ---
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Call the Gemini helper function
|
||||||
|
extracted_items = await extract_items_from_image_gemini(image_bytes=contents)
|
||||||
|
|
||||||
|
logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.")
|
||||||
|
return OcrExtractResponse(extracted_items=extracted_items)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
# Handle errors from Gemini processing (blocked, empty response, etc.)
|
||||||
|
logger.warning(f"Gemini processing error for user {current_user.email}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, # Or 400 Bad Request?
|
||||||
|
detail=f"Could not extract items from image: {e}",
|
||||||
|
)
|
||||||
|
except google_exceptions.ResourceExhausted as e:
|
||||||
|
# Specific handling for quota errors
|
||||||
|
logger.error(f"Gemini Quota Exceeded for user {current_user.email}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="OCR service quota exceeded. Please try again later.",
|
||||||
|
)
|
||||||
|
except google_exceptions.GoogleAPIError as e:
|
||||||
|
# Handle other Google API errors (e.g., invalid key, permissions)
|
||||||
|
logger.error(f"Gemini API Error for user {current_user.email}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"OCR service error: {e}",
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
# Catch initialization errors from get_gemini_client()
|
||||||
|
logger.error(f"Gemini client runtime error during OCR request: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"OCR service configuration error: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Catch any other unexpected errors
|
||||||
|
logger.exception(f"Unexpected error during OCR extraction for user {current_user.email}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="An unexpected error occurred during item extraction.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Ensure file handle is closed (UploadFile uses SpooledTemporaryFile)
|
||||||
|
await image_file.close()
|
@ -2,11 +2,14 @@
|
|||||||
import os
|
import os
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import logging
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
DATABASE_URL: str | None = None
|
DATABASE_URL: str | None = None
|
||||||
|
GEMINI_API_KEY: str | None = None
|
||||||
|
|
||||||
# --- JWT Settings ---
|
# --- JWT Settings ---
|
||||||
# Generate a strong secret key using: openssl rand -hex 32
|
# Generate a strong secret key using: openssl rand -hex 32
|
||||||
@ -35,3 +38,11 @@ if settings.SECRET_KEY == "a_very_insecure_default_secret_key_replace_me":
|
|||||||
# Consider raising an error in a production environment check
|
# Consider raising an error in a production environment check
|
||||||
# if os.getenv("ENVIRONMENT") == "production":
|
# if os.getenv("ENVIRONMENT") == "production":
|
||||||
# raise ValueError("Default SECRET_KEY is not allowed in production!")
|
# raise ValueError("Default SECRET_KEY is not allowed in production!")
|
||||||
|
|
||||||
|
if settings.GEMINI_API_KEY is None:
|
||||||
|
print.error("CRITICAL: GEMINI_API_KEY environment variable not set. Gemini features will be unavailable.")
|
||||||
|
# You might raise an error here if Gemini is essential for startup
|
||||||
|
# raise ValueError("GEMINI_API_KEY must be set.")
|
||||||
|
else:
|
||||||
|
# Optional: Log partial key for confirmation (avoid logging full key)
|
||||||
|
logger.info(f"GEMINI_API_KEY loaded (starts with: {settings.GEMINI_API_KEY[:4]}...).")
|
154
be/app/core/gemini.py
Normal file
154
be/app/core/gemini.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# app/core/gemini.py
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
import google.generativeai as genai
|
||||||
|
from google.generativeai.types import HarmCategory, HarmBlockThreshold # For safety settings
|
||||||
|
from google.api_core import exceptions as google_exceptions
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# --- Global variable to hold the initialized model client ---
|
||||||
|
gemini_flash_client = None
|
||||||
|
gemini_initialization_error = None # Store potential init error
|
||||||
|
|
||||||
|
# --- Configure and Initialize ---
|
||||||
|
try:
|
||||||
|
if settings.GEMINI_API_KEY:
|
||||||
|
genai.configure(api_key=settings.GEMINI_API_KEY)
|
||||||
|
# Initialize the specific model we want to use
|
||||||
|
gemini_flash_client = genai.GenerativeModel(
|
||||||
|
model_name="gemini-2.0-flash",
|
||||||
|
# Optional: Add default safety settings
|
||||||
|
# Adjust these based on your expected content and risk tolerance
|
||||||
|
safety_settings={
|
||||||
|
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||||
|
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||||
|
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||||
|
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||||
|
},
|
||||||
|
# Optional: Add default generation config (can be overridden per request)
|
||||||
|
# generation_config=genai.types.GenerationConfig(
|
||||||
|
# # candidate_count=1, # Usually default is 1
|
||||||
|
# # stop_sequences=["\n"],
|
||||||
|
# # max_output_tokens=2048,
|
||||||
|
# # temperature=0.9, # Controls randomness (0=deterministic, >1=more random)
|
||||||
|
# # top_p=1,
|
||||||
|
# # top_k=1
|
||||||
|
# )
|
||||||
|
)
|
||||||
|
logger.info("Gemini AI client initialized successfully for model 'gemini-1.5-flash-latest'.")
|
||||||
|
else:
|
||||||
|
# Store error if API key is missing
|
||||||
|
gemini_initialization_error = "GEMINI_API_KEY not configured. Gemini client not initialized."
|
||||||
|
logger.error(gemini_initialization_error)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Catch any other unexpected errors during initialization
|
||||||
|
gemini_initialization_error = f"Failed to initialize Gemini AI client: {e}"
|
||||||
|
logger.exception(gemini_initialization_error) # Log full traceback
|
||||||
|
gemini_flash_client = None # Ensure client is None on error
|
||||||
|
|
||||||
|
|
||||||
|
# --- Function to get the client (optional, allows checking error) ---
|
||||||
|
def get_gemini_client():
|
||||||
|
"""
|
||||||
|
Returns the initialized Gemini client instance.
|
||||||
|
Raises an exception if initialization failed.
|
||||||
|
"""
|
||||||
|
if gemini_initialization_error:
|
||||||
|
raise RuntimeError(f"Gemini client could not be initialized: {gemini_initialization_error}")
|
||||||
|
if gemini_flash_client is None:
|
||||||
|
# This case should ideally be covered by the check above, but as a safeguard:
|
||||||
|
raise RuntimeError("Gemini client is not available (unknown initialization issue).")
|
||||||
|
return gemini_flash_client
|
||||||
|
|
||||||
|
# Define the prompt as a constant
|
||||||
|
OCR_ITEM_EXTRACTION_PROMPT = """
|
||||||
|
Extract the shopping list items from this image.
|
||||||
|
List each distinct item on a new line.
|
||||||
|
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
|
||||||
|
Focus only on the names of the products or items to be purchased.
|
||||||
|
If the image does not appear to be a shopping list or receipt, state that clearly.
|
||||||
|
Example output for a grocery list:
|
||||||
|
Milk
|
||||||
|
Eggs
|
||||||
|
Bread
|
||||||
|
Apples
|
||||||
|
Organic Bananas
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def extract_items_from_image_gemini(image_bytes: bytes) -> List[str]:
|
||||||
|
"""
|
||||||
|
Uses Gemini Flash to extract shopping list items from image bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_bytes: The image content as bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of extracted item strings.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If the Gemini client is not initialized.
|
||||||
|
google_exceptions.GoogleAPIError: For API call errors (quota, invalid key etc.).
|
||||||
|
ValueError: If the response is blocked or contains no usable text.
|
||||||
|
"""
|
||||||
|
client = get_gemini_client() # Raises RuntimeError if not initialized
|
||||||
|
|
||||||
|
# Prepare image part for multimodal input
|
||||||
|
image_part = {
|
||||||
|
"mime_type": "image/jpeg", # Or image/png, image/webp etc. Adjust if needed or detect mime type
|
||||||
|
"data": image_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prepare the full prompt content
|
||||||
|
prompt_parts = [
|
||||||
|
OCR_ITEM_EXTRACTION_PROMPT, # Text prompt first
|
||||||
|
image_part # Then the image
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info("Sending image to Gemini for item extraction...")
|
||||||
|
try:
|
||||||
|
# Make the API call
|
||||||
|
# Use generate_content_async for async FastAPI
|
||||||
|
response = await client.generate_content_async(prompt_parts)
|
||||||
|
|
||||||
|
# --- Process the response ---
|
||||||
|
# Check for safety blocks or lack of content
|
||||||
|
if not response.candidates or not response.candidates[0].content.parts:
|
||||||
|
logger.warning("Gemini response blocked or empty.", extra={"response": response})
|
||||||
|
# Check finish_reason if available
|
||||||
|
finish_reason = response.candidates[0].finish_reason if response.candidates else 'UNKNOWN'
|
||||||
|
safety_ratings = response.candidates[0].safety_ratings if response.candidates else 'N/A'
|
||||||
|
if finish_reason == 'SAFETY':
|
||||||
|
raise ValueError(f"Gemini response blocked due to safety settings. Ratings: {safety_ratings}")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Gemini response was empty or incomplete. Finish Reason: {finish_reason}")
|
||||||
|
|
||||||
|
# Extract text - assumes the first part of the first candidate is the text response
|
||||||
|
raw_text = response.text # response.text is a shortcut for response.candidates[0].content.parts[0].text
|
||||||
|
logger.info("Received raw text from Gemini.")
|
||||||
|
# logger.debug(f"Gemini Raw Text:\n{raw_text}") # Optional: Log full response text
|
||||||
|
|
||||||
|
# Parse the text response
|
||||||
|
items = []
|
||||||
|
for line in raw_text.splitlines(): # Split by newline
|
||||||
|
cleaned_line = line.strip() # Remove leading/trailing whitespace
|
||||||
|
# Basic filtering: ignore empty lines and potential non-item lines
|
||||||
|
if cleaned_line and len(cleaned_line) > 1: # Ignore very short lines too?
|
||||||
|
# Add more sophisticated filtering if needed (e.g., regex, keyword check)
|
||||||
|
items.append(cleaned_line)
|
||||||
|
|
||||||
|
logger.info(f"Extracted {len(items)} potential items.")
|
||||||
|
return items
|
||||||
|
|
||||||
|
except google_exceptions.GoogleAPIError as e:
|
||||||
|
logger.error(f"Gemini API Error: {e}", exc_info=True)
|
||||||
|
# Re-raise specific Google API errors for endpoint to handle (e.g., quota)
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
# Catch other unexpected errors during generation or processing
|
||||||
|
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)
|
||||||
|
# Wrap in a generic ValueError or re-raise
|
||||||
|
raise ValueError(f"Failed to process image with Gemini: {e}") from e
|
83
be/app/core/test_gemini.py
Normal file
83
be/app/core/test_gemini.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# be/tests/core/test_gemini.py
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
# Store original key if exists, then clear it for testing missing key scenario
|
||||||
|
original_api_key = os.environ.get("GEMINI_API_KEY")
|
||||||
|
if "GEMINI_API_KEY" in os.environ:
|
||||||
|
del os.environ["GEMINI_API_KEY"]
|
||||||
|
|
||||||
|
# --- Test Module Import ---
|
||||||
|
# This forces the module-level initialization code in gemini.py to run
|
||||||
|
# We need to reload modules because settings might have been cached
|
||||||
|
from importlib import reload
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
from app.core import gemini as gemini_core
|
||||||
|
|
||||||
|
# Reload settings first to ensure GEMINI_API_KEY is None initially
|
||||||
|
reload(app_settings)
|
||||||
|
# Reload gemini core to trigger initialization logic with potentially missing key
|
||||||
|
reload(gemini_core)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemini_initialization_without_key():
|
||||||
|
"""Verify behavior when GEMINI_API_KEY is not set."""
|
||||||
|
# Reload modules again to ensure clean state for this specific test
|
||||||
|
if "GEMINI_API_KEY" in os.environ:
|
||||||
|
del os.environ["GEMINI_API_KEY"]
|
||||||
|
reload(app_settings)
|
||||||
|
reload(gemini_core)
|
||||||
|
|
||||||
|
assert gemini_core.gemini_flash_client is None
|
||||||
|
assert gemini_core.gemini_initialization_error is not None
|
||||||
|
assert "GEMINI_API_KEY not configured" in gemini_core.gemini_initialization_error
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="GEMINI_API_KEY not configured"):
|
||||||
|
gemini_core.get_gemini_client()
|
||||||
|
|
||||||
|
@patch('google.generativeai.configure')
|
||||||
|
@patch('google.generativeai.GenerativeModel')
|
||||||
|
def test_gemini_initialization_with_key(mock_generative_model: MagicMock, mock_configure: MagicMock):
|
||||||
|
"""Verify initialization logic is called when key is present (using mocks)."""
|
||||||
|
# Set a dummy key in the environment for this test
|
||||||
|
test_key = "TEST_API_KEY_123"
|
||||||
|
os.environ["GEMINI_API_KEY"] = test_key
|
||||||
|
|
||||||
|
# Reload settings and gemini module to pick up the new key
|
||||||
|
reload(app_settings)
|
||||||
|
reload(gemini_core)
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
mock_configure.assert_called_once_with(api_key=test_key)
|
||||||
|
mock_generative_model.assert_called_once_with(
|
||||||
|
model_name="gemini-1.5-flash-latest",
|
||||||
|
safety_settings=pytest.ANY, # Check safety settings were passed (ANY allows flexibility)
|
||||||
|
# generation_config=pytest.ANY # Check if you added default generation config
|
||||||
|
)
|
||||||
|
assert gemini_core.gemini_flash_client is not None
|
||||||
|
assert gemini_core.gemini_initialization_error is None
|
||||||
|
|
||||||
|
# Test get_gemini_client() success path
|
||||||
|
client = gemini_core.get_gemini_client()
|
||||||
|
assert client is not None # Should return the mocked client instance
|
||||||
|
|
||||||
|
# Clean up environment variable after test
|
||||||
|
if original_api_key:
|
||||||
|
os.environ["GEMINI_API_KEY"] = original_api_key
|
||||||
|
else:
|
||||||
|
if "GEMINI_API_KEY" in os.environ:
|
||||||
|
del os.environ["GEMINI_API_KEY"]
|
||||||
|
# Reload modules one last time to restore state for other tests
|
||||||
|
reload(app_settings)
|
||||||
|
reload(gemini_core)
|
||||||
|
|
||||||
|
# Restore original key after all tests in the module run (if needed)
|
||||||
|
def teardown_module(module):
|
||||||
|
if original_api_key:
|
||||||
|
os.environ["GEMINI_API_KEY"] = original_api_key
|
||||||
|
else:
|
||||||
|
if "GEMINI_API_KEY" in os.environ:
|
||||||
|
del os.environ["GEMINI_API_KEY"]
|
||||||
|
reload(app_settings)
|
||||||
|
reload(gemini_core)
|
6
be/app/schemas/ocr.py
Normal file
6
be/app/schemas/ocr.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# app/schemas/ocr.py
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
class OcrExtractResponse(BaseModel):
|
||||||
|
extracted_items: List[str] # A list of potential item names
|
@ -9,3 +9,4 @@ python-dotenv>=1.0.0 # To load .env file for scripts/alembic
|
|||||||
passlib[bcrypt]>=1.7.4
|
passlib[bcrypt]>=1.7.4
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
pydantic[email]
|
pydantic[email]
|
||||||
|
google-generativeai>=0.5.0
|
@ -39,176 +39,95 @@ export class ApiClientError extends Error {
|
|||||||
|
|
||||||
// --- Request Options Interface ---
|
// --- Request Options Interface ---
|
||||||
// Extends standard RequestInit but omits 'body' as we handle it separately
|
// Extends standard RequestInit but omits 'body' as we handle it separately
|
||||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
interface RequestOptions extends Omit<RequestInit, 'body' | 'headers'> {
|
||||||
// Can add custom options here later, e.g.:
|
headers?: HeadersInit;
|
||||||
// skipAuth?: boolean; // To bypass adding the Authorization header
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Core Request Function ---
|
// --- Core Request Function ---
|
||||||
// Uses generics <T> to allow specifying the expected successful response data type
|
// Uses generics <T> to allow specifying the expected successful response data type
|
||||||
async function request<T = unknown>(
|
async function request<T = unknown>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string, // Relative path to the API endpoint (e.g., /v1/users/me)
|
path: string,
|
||||||
bodyData?: unknown, // Optional data for the request body (can be object, FormData, URLSearchParams, etc.)
|
bodyData?: unknown,
|
||||||
options: RequestOptions = {} // Optional fetch options (headers, credentials, mode, etc.)
|
options: RequestOptions = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
|
||||||
// Runtime check for BASE_URL, in case it wasn't set or available during initial load
|
|
||||||
if (!BASE_URL) {
|
if (!BASE_URL) {
|
||||||
// Depending on context (load function vs. component event), choose how to handle
|
throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.');
|
||||||
// error(500, 'API Base URL is not configured.'); // Use in load functions
|
|
||||||
throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.'); // Throw for component events
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the full URL safely
|
const cleanBase = BASE_URL.replace(/\/$/, '');
|
||||||
const cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base
|
const cleanPath = path.replace(/^\//, '');
|
||||||
const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path
|
|
||||||
const url = `${cleanBase}/${cleanPath}`;
|
const url = `${cleanBase}/${cleanPath}`;
|
||||||
|
|
||||||
// Initialize headers, setting Accept to JSON by default
|
// --- Refined Header Handling ---
|
||||||
const headers = new Headers({
|
const headers = new Headers({ Accept: 'application/json' });
|
||||||
Accept: 'application/json',
|
|
||||||
...options.headers // Spread custom headers provided in options early
|
if (options.headers) {
|
||||||
|
new Headers(options.headers).forEach((value, key) => {
|
||||||
|
headers.set(key, value);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Prepare Request Body and Set Content-Type ---
|
// --- Prepare Request Body and Set Content-Type ---
|
||||||
let processedBody: BodyInit | null = null;
|
let processedBody: BodyInit | null = null;
|
||||||
|
|
||||||
if (bodyData !== undefined && bodyData !== null) {
|
if (bodyData !== undefined && bodyData !== null) {
|
||||||
if (bodyData instanceof URLSearchParams) {
|
if (bodyData instanceof URLSearchParams) {
|
||||||
// Handle URL-encoded form data
|
|
||||||
headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
processedBody = bodyData;
|
processedBody = bodyData;
|
||||||
} else if (bodyData instanceof FormData) {
|
} else if (bodyData instanceof FormData) {
|
||||||
// Handle FormData (multipart/form-data)
|
|
||||||
// Let the browser set the Content-Type with the correct boundary
|
|
||||||
// Important: DO NOT set 'Content-Type' manually for FormData
|
|
||||||
// headers.delete('Content-Type'); // Ensure no manual Content-Type is set
|
|
||||||
processedBody = bodyData;
|
processedBody = bodyData;
|
||||||
} else if (typeof bodyData === 'object') {
|
} else if (typeof bodyData === 'object') {
|
||||||
// Handle plain JavaScript objects as JSON
|
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
try {
|
try { processedBody = JSON.stringify(bodyData); }
|
||||||
processedBody = JSON.stringify(bodyData);
|
catch (e) { throw new Error("Invalid JSON body data provided."); }
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to stringify JSON body data:", bodyData, e);
|
|
||||||
throw new Error("Invalid JSON body data provided.");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Handle other primitives (string, number, boolean) - default to sending as JSON stringified
|
|
||||||
// Adjust this logic if you need to send plain text or other formats
|
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
try {
|
try { processedBody = JSON.stringify(bodyData); }
|
||||||
processedBody = JSON.stringify(bodyData)
|
catch (e) { throw new Error("Invalid body data provided."); }
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to stringify primitive body data:", bodyData, e);
|
|
||||||
throw new Error("Invalid body data provided.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// --- End Body Preparation ---
|
|
||||||
|
|
||||||
// --- Add Authorization Header ---
|
// --- Add Authorization Header ---
|
||||||
const currentToken = getCurrentToken(); // Get token synchronously from auth store
|
const currentToken = getCurrentToken();
|
||||||
// Add header if token exists and Authorization wasn't manually set in options.headers
|
|
||||||
if (currentToken && !headers.has('Authorization')) {
|
if (currentToken && !headers.has('Authorization')) {
|
||||||
headers.set('Authorization', `Bearer ${currentToken}`);
|
headers.set('Authorization', `Bearer ${currentToken}`);
|
||||||
}
|
}
|
||||||
// --- End Authorization Header ---
|
|
||||||
|
|
||||||
// Assemble final fetch options
|
// --- Assemble fetch options carefully ---
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
headers,
|
headers: headers,
|
||||||
body: processedBody, // Use the potentially processed body
|
body: processedBody,
|
||||||
credentials: options.credentials ?? 'same-origin', // Default credentials policy
|
|
||||||
mode: options.mode ?? 'cors', // Default mode
|
|
||||||
cache: options.cache ?? 'default', // Default cache policy
|
|
||||||
...options // Spread remaining options, potentially overriding defaults if needed
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { headers: _, ...restOfOptions } = options;
|
||||||
|
Object.assign(fetchOptions, restOfOptions);
|
||||||
|
|
||||||
|
fetchOptions.credentials = fetchOptions.credentials ?? 'same-origin';
|
||||||
|
fetchOptions.mode = fetchOptions.mode ?? 'cors';
|
||||||
|
fetchOptions.cache = fetchOptions.cache ?? 'default';
|
||||||
|
|
||||||
// --- Execute Fetch and Handle Response ---
|
// --- Execute Fetch and Handle Response ---
|
||||||
try {
|
try {
|
||||||
// Optional: Log request details for debugging
|
|
||||||
// console.debug(`API Request: ${fetchOptions.method} ${url}`, { headers: Object.fromEntries(headers.entries()), body: bodyData });
|
|
||||||
const response = await fetch(url, fetchOptions);
|
const response = await fetch(url, fetchOptions);
|
||||||
// Optional: Log response status
|
|
||||||
// console.debug(`API Response Status: ${response.status} for ${fetchOptions.method} ${url}`);
|
|
||||||
|
|
||||||
// Check if the response status code indicates failure (not 2xx)
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorJson: unknown = null;
|
let errorJson: unknown = null;
|
||||||
// Attempt to parse error details from the response body
|
try { errorJson = await response.json(); }
|
||||||
try {
|
catch (e) { /* ignore */ }
|
||||||
errorJson = await response.json();
|
const errorToThrow = new ApiClientError(`HTTP Error ${response.status}`, response.status, errorJson);
|
||||||
// console.debug(`API Error Response Body:`, errorJson);
|
if (response.status === 401) { logout(); }
|
||||||
} catch (e) {
|
|
||||||
// Ignore if response body isn't valid JSON or empty
|
|
||||||
console.warn(`API Error response for ${response.status} was not valid JSON or empty.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the custom error object
|
|
||||||
const errorToThrow = new ApiClientError(
|
|
||||||
`API request failed: ${response.status} ${response.statusText}`,
|
|
||||||
response.status,
|
|
||||||
errorJson
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Global 401 (Unauthorized) Handling ---
|
|
||||||
// If the server returns 401, assume the token is invalid/expired
|
|
||||||
// and automatically log the user out by clearing the auth store.
|
|
||||||
if (response.status === 401) {
|
|
||||||
console.warn(`API Client: Received 401 Unauthorized for ${method} ${path}. Logging out.`);
|
|
||||||
// Calling logout clears the token from store & localStorage
|
|
||||||
logout();
|
|
||||||
// Optional: Trigger a redirect to login page. Often better handled
|
|
||||||
// by calling code or root layout based on application structure.
|
|
||||||
// import { goto } from '$app/navigation';
|
|
||||||
// if (browser) await goto('/login?sessionExpired=true');
|
|
||||||
}
|
|
||||||
// --- End Global 401 Handling ---
|
|
||||||
|
|
||||||
// Throw the error regardless, so the calling code knows the request failed
|
|
||||||
throw errorToThrow;
|
throw errorToThrow;
|
||||||
}
|
}
|
||||||
|
if (response.status === 204) { return null as T; }
|
||||||
// Handle successful responses with no content (e.g., 204 No Content for DELETE)
|
return (await response.json()) as T;
|
||||||
if (response.status === 204) {
|
|
||||||
// Assert type as T, assuming T can accommodate null or void if needed
|
|
||||||
return null as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse successful JSON response body
|
|
||||||
const responseData = await response.json();
|
|
||||||
// Assert the response data matches the expected generic type T
|
|
||||||
return responseData as T;
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Handle network errors (fetch throws TypeError) or errors thrown above
|
if (err instanceof ApiClientError && err.status === 401) { logout(); }
|
||||||
console.error(`API Client request error during ${method} ${path}:`, err);
|
if (err instanceof ApiClientError) { throw err; }
|
||||||
|
throw new ApiClientError('Unknown error occurred', 0, err);
|
||||||
// Ensure logout is called even if the caught error is a 401 ApiClientError
|
}
|
||||||
// This handles cases where parsing a non-ok response might fail but status was 401
|
|
||||||
if (err instanceof ApiClientError && err.status === 401) {
|
|
||||||
console.warn(`API Client: Caught ApiClientError 401 for ${method} ${path}. Ensuring logout.`);
|
|
||||||
// Ensure logout state is cleared even if error originated elsewhere
|
|
||||||
logout();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-throw the error so the calling code can handle it appropriately
|
|
||||||
// If it's already our custom error, re-throw it directly
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, wrap network or other unexpected errors in our custom error type
|
|
||||||
throw new ApiClientError(
|
|
||||||
`Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
0, // Use 0 or a specific code (e.g., -1) for non-HTTP errors
|
|
||||||
err // Include the original error object as data
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) ---
|
// --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) ---
|
||||||
// Provide simple wrappers around the core 'request' function
|
// Provide simple wrappers around the core 'request' function
|
||||||
|
234
fe/src/lib/components/ImageOcrInput.svelte
Normal file
234
fe/src/lib/components/ImageOcrInput.svelte
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
<!-- src/lib/components/ImageOcrInput.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
imageSelected: File; // Dispatch the selected file object
|
||||||
|
cancel: void; // Dispatch when user cancels
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let selectedFile: File | null = null;
|
||||||
|
let previewUrl: string | null = null;
|
||||||
|
let inputKey = Date.now(); // Key to reset file input if needed
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
// Refs for the input elements
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
let captureInput: HTMLInputElement;
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_MB = 10; // Match backend limit
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
|
function handleFileChange(event: Event) {
|
||||||
|
error = null; // Clear previous error
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
// Basic Validation
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
error = `Invalid file type. Please select JPEG, PNG, or WEBP. Type found: ${file.type}`;
|
||||||
|
resetInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
||||||
|
error = `File is too large (max ${MAX_FILE_SIZE_MB}MB). Size: ${(file.size / 1024 / 1024).toFixed(2)}MB`;
|
||||||
|
resetInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFile = file;
|
||||||
|
// Create a preview URL
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl); // Revoke previous URL
|
||||||
|
previewUrl = URL.createObjectURL(file);
|
||||||
|
console.log('Image selected:', file.name, file.type, file.size);
|
||||||
|
} else {
|
||||||
|
// No file selected (e.g., user cancelled file picker)
|
||||||
|
// Optionally clear existing selection if needed
|
||||||
|
// clearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedFile = null;
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
previewUrl = null;
|
||||||
|
}
|
||||||
|
error = null;
|
||||||
|
resetInput(); // Reset the input fields
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetInput() {
|
||||||
|
// Changing the key forces Svelte to recreate the input, clearing its value
|
||||||
|
inputKey = Date.now();
|
||||||
|
// Also reset the value manually in case key trick doesn't work everywhere
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
if (captureInput) captureInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileInput() {
|
||||||
|
fileInput?.click(); // Programmatically click the hidden file input
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerCaptureInput() {
|
||||||
|
captureInput?.click(); // Programmatically click the hidden capture input
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (selectedFile) {
|
||||||
|
dispatch('imageSelected', selectedFile);
|
||||||
|
} else {
|
||||||
|
error = 'Please select or capture an image first.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
clearSelection();
|
||||||
|
dispatch('cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up object URL when component is destroyed
|
||||||
|
onDestroy(() => {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
import { onDestroy } from 'svelte'; // Ensure onDestroy is imported
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Basic Modal Structure (adapt styling as needed) -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="bg-opacity-60 fixed inset-0 z-50 flex items-center justify-center bg-black p-4"
|
||||||
|
on:click|self={handleCancel}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="ocr-modal-title"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h2 id="ocr-modal-title" class="mb-4 text-xl font-semibold text-gray-800">
|
||||||
|
Add Items via Photo
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Hidden Inputs -->
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg, image/png, image/webp"
|
||||||
|
on:change={handleFileChange}
|
||||||
|
bind:this={fileInput}
|
||||||
|
key={inputKey}
|
||||||
|
class="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg, image/png, image/webp"
|
||||||
|
capture="environment"
|
||||||
|
on:change={handleFileChange}
|
||||||
|
bind:this={captureInput}
|
||||||
|
key={inputKey}
|
||||||
|
class="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if previewUrl}
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<p class="mb-2 text-sm text-gray-600">Image Preview:</p>
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Selected list preview"
|
||||||
|
class="mx-auto max-h-60 w-auto rounded border border-gray-300 object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={clearSelection}
|
||||||
|
class="mt-2 text-xs text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
Clear Selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={triggerCaptureInput}
|
||||||
|
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
|
||||||
|
>
|
||||||
|
<!-- Basic Camera Icon -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mr-2 h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||||
|
/><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
Take Photo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={triggerFileInput}
|
||||||
|
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
|
||||||
|
>
|
||||||
|
<!-- Basic Upload Icon -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mr-2 h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmation/Cancel -->
|
||||||
|
<div class="mt-6 flex justify-end space-x-3 border-t pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={handleCancel}
|
||||||
|
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={handleConfirm}
|
||||||
|
disabled={!selectedFile}
|
||||||
|
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Confirm Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
165
fe/src/lib/components/OcrReview.svelte
Normal file
165
fe/src/lib/components/OcrReview.svelte
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<!-- src/lib/components/OcrReview.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount, tick } from 'svelte'; // Added tick
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
/** Initial list of item names extracted by OCR */
|
||||||
|
export let initialItems: string[] = [];
|
||||||
|
|
||||||
|
// Events
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
confirm: string[]; // Final list of item names
|
||||||
|
cancel: void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Internal State
|
||||||
|
interface ReviewItem {
|
||||||
|
id: string; // Unique key for {#each} and focus management
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
let reviewedItems: ReviewItem[] = [];
|
||||||
|
export let isLoading: boolean = false; // Add isLoading prop
|
||||||
|
let inputRefs: Record<string, HTMLInputElement> = {}; // To store references for focusing
|
||||||
|
|
||||||
|
// Initialize items with unique IDs when component mounts or prop changes
|
||||||
|
$: if (initialItems) {
|
||||||
|
reviewedItems = initialItems.map((name) => ({
|
||||||
|
id: crypto.randomUUID(), // Generate unique ID for each item
|
||||||
|
name: name
|
||||||
|
}));
|
||||||
|
console.log('OcrReview initialized with items:', reviewedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes an item from the review list */
|
||||||
|
function deleteItem(idToDelete: string) {
|
||||||
|
reviewedItems = reviewedItems.filter((item) => item.id !== idToDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a new, empty input field to the list */
|
||||||
|
async function addItemManually() {
|
||||||
|
const newItemId = crypto.randomUUID();
|
||||||
|
// Add a new empty item at the end
|
||||||
|
reviewedItems = [...reviewedItems, { id: newItemId, name: '' }];
|
||||||
|
// Wait for the DOM to update, then focus the new input
|
||||||
|
await tick();
|
||||||
|
if (inputRefs[newItemId]) {
|
||||||
|
inputRefs[newItemId].focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatches the confirmed list of non-empty item names */
|
||||||
|
function handleConfirm() {
|
||||||
|
// Filter out empty items and extract just the names
|
||||||
|
const finalItemNames = reviewedItems
|
||||||
|
.map((item) => item.name.trim())
|
||||||
|
.filter((name) => name.length > 0);
|
||||||
|
|
||||||
|
console.log('OcrReview confirming items:', finalItemNames);
|
||||||
|
dispatch('confirm', finalItemNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatches the cancel event */
|
||||||
|
function handleCancel() {
|
||||||
|
dispatch('cancel');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Modal Structure -->
|
||||||
|
<div
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4"
|
||||||
|
on:click|self={handleCancel}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="ocr-review-title"
|
||||||
|
>
|
||||||
|
<!-- Prevent backdrop click from closing when clicking inside modal content -->
|
||||||
|
<div
|
||||||
|
class="flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl"
|
||||||
|
on:click|stopPropagation
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex-shrink-0 border-b p-4">
|
||||||
|
<h2 id="ocr-review-title" class="text-xl font-semibold text-gray-800">
|
||||||
|
Review Extracted Items
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Edit, delete, or add items below. Confirm to add them to your list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item List (Scrollable) -->
|
||||||
|
<div class="flex-grow overflow-y-auto p-4">
|
||||||
|
{#if reviewedItems.length > 0}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each reviewedItems as item (item.id)}
|
||||||
|
<li class="flex items-center gap-2" animate:flip={{ duration: 200 }}>
|
||||||
|
<!-- Bind input element reference using item.id as key -->
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={item.name}
|
||||||
|
bind:this={inputRefs[item.id]}
|
||||||
|
placeholder="Enter item name..."
|
||||||
|
class="flex-grow rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
aria-label="Item name"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
on:click={() => deleteItem(item.id)}
|
||||||
|
title="Remove item"
|
||||||
|
class="flex-shrink-0 rounded p-1 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-1 focus:ring-red-400"
|
||||||
|
aria-label="Remove item"
|
||||||
|
>
|
||||||
|
<!-- Basic 'X' icon -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="py-4 text-center text-sm text-gray-500">
|
||||||
|
No items extracted. Add items manually below.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Manually Button -->
|
||||||
|
<div class="flex-shrink-0 px-4 pb-4">
|
||||||
|
<button
|
||||||
|
on:click={addItemManually}
|
||||||
|
class="w-full rounded border border-dashed border-gray-400 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
+ Add Item Manually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Actions -->
|
||||||
|
<div class="flex flex-shrink-0 justify-end space-x-3 border-t bg-gray-50 p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={handleCancel}
|
||||||
|
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={handleConfirm}
|
||||||
|
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Adding...' : 'Confirm & Add Items'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
8
fe/src/lib/schemas/ocr.ts
Normal file
8
fe/src/lib/schemas/ocr.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface OcrExtractResponse {
|
||||||
|
extracted_items: string[]; // Matches the backend schema
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrReviewItem {
|
||||||
|
id: number; // Temporary unique ID for the {#each} key
|
||||||
|
text: string; // The item name, editable
|
||||||
|
}
|
@ -3,24 +3,27 @@
|
|||||||
// Svelte/SvelteKit Imports
|
// Svelte/SvelteKit Imports
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import type { PageData } from '../$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
// Component Imports
|
// Component Imports
|
||||||
import ItemDisplay from '$lib/components/ItemDisplay.svelte';
|
import ItemDisplay from '$lib/components/ItemDisplay.svelte';
|
||||||
|
import ImageOcrInput from '$lib/components/ImageOcrInput.svelte';
|
||||||
|
import OcrReview from '$lib/components/OcrReview.svelte'; // Import Review Component
|
||||||
|
|
||||||
// Utility/Store Imports
|
// Utility/Store Imports
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
import { apiClient, ApiClientError } from '$lib/apiClient';
|
||||||
import { authStore } from '$lib/stores/authStore'; // Get current user ID
|
import { authStore } from '$lib/stores/authStore';
|
||||||
import { get, writable } from 'svelte/store'; // For local reactive list state
|
import { writable, get } from 'svelte/store';
|
||||||
|
|
||||||
// Schema Imports
|
// Schema Imports
|
||||||
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item';
|
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item';
|
||||||
import type { ListDetail, ListStatus } from '$lib/schemas/list';
|
import type { ListDetail, ListStatus } from '$lib/schemas/list';
|
||||||
|
import type { OcrExtractResponse } from '$lib/schemas/ocr';
|
||||||
|
import type { Message } from '$lib/schemas/message';
|
||||||
|
|
||||||
// --- DB and Sync Imports ---
|
// --- DB and Sync Imports ---
|
||||||
import {
|
import {
|
||||||
getListFromDb,
|
getListFromDb,
|
||||||
getItemsByListIdFromDb,
|
|
||||||
putListToDb,
|
putListToDb,
|
||||||
putItemToDb,
|
putItemToDb,
|
||||||
deleteItemFromDb,
|
deleteItemFromDb,
|
||||||
@ -46,7 +49,7 @@
|
|||||||
|
|
||||||
// --- General Item Error Display ---
|
// --- General Item Error Display ---
|
||||||
let itemUpdateError: string | null = null;
|
let itemUpdateError: string | null = null;
|
||||||
let itemErrorTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
let itemErrorTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
// --- Polling State ---
|
// --- Polling State ---
|
||||||
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
|
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
@ -59,12 +62,20 @@
|
|||||||
let isRefreshing = false;
|
let isRefreshing = false;
|
||||||
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds
|
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds
|
||||||
|
|
||||||
|
// --- OCR State ---
|
||||||
|
let showOcrModal = false;
|
||||||
|
let isProcessingOcr = false; // Loading state for API call
|
||||||
|
let ocrError: string | null = null; // Error during API call
|
||||||
|
let showOcrReview = false; // Controls review modal visibility
|
||||||
|
let ocrResults: string[] = []; // Stores results from OCR API
|
||||||
|
let isConfirmingOcrItems = false; // Loading state for adding items after review
|
||||||
|
let confirmOcrError: string | null = null; // Error during final add after review
|
||||||
|
// --- End OCR State ---
|
||||||
|
|
||||||
// --- Lifecycle ---
|
// --- Lifecycle ---
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
let listId: number | null = null;
|
let listId: number | null = null;
|
||||||
|
(async () => {
|
||||||
try {
|
try {
|
||||||
listId = parseInt($page.params.listId, 10);
|
listId = parseInt($page.params.listId, 10);
|
||||||
} catch {
|
} catch {
|
||||||
@ -73,50 +84,39 @@
|
|||||||
|
|
||||||
if (!listId) {
|
if (!listId) {
|
||||||
console.error('List Detail Mount: Invalid or missing listId in params.');
|
console.error('List Detail Mount: Invalid or missing listId in params.');
|
||||||
// Optionally redirect or show permanent error
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Load from IndexedDB first for faster initial display/offline
|
// 1. Load from IndexedDB first
|
||||||
if (browser) {
|
if (browser) {
|
||||||
console.log('List Detail Mount: Loading from IndexedDB for list', listId);
|
console.log('List Detail Mount: Loading from IndexedDB for list', listId);
|
||||||
const listFromDb = await getListFromDb(listId);
|
const listFromDb = await getListFromDb(listId);
|
||||||
if (listFromDb) {
|
if (listFromDb) {
|
||||||
console.log('List Detail Mount: Found list in DB', listFromDb);
|
console.log('List Detail Mount: Found list in DB', listFromDb);
|
||||||
// Items should be part of ListDetail object store
|
|
||||||
if (isMounted) {
|
|
||||||
localListStore.set(listFromDb);
|
localListStore.set(listFromDb);
|
||||||
initializePollingStatus(listFromDb);
|
initializePollingStatus(listFromDb);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log('List Detail Mount: List not found in DB, using SSR/load data.');
|
console.log('List Detail Mount: List not found in DB, using SSR/load data.');
|
||||||
if (isMounted) {
|
localListStore.set(data.list);
|
||||||
localListStore.set(data.list); // Fallback to initial data
|
|
||||||
initializePollingStatus(data.list);
|
initializePollingStatus(data.list);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If online, trigger an API fetch in background to update DB/UI
|
// 2. If online, fetch fresh data in background
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
console.log('List Detail Mount: Online, fetching fresh data...');
|
console.log('List Detail Mount: Online, fetching fresh data...');
|
||||||
fetchAndUpdateList(listId); // Don't await, let it run in background
|
fetchAndUpdateList(listId); // Don't await
|
||||||
// Also trigger sync queue processing
|
|
||||||
processSyncQueue(); // Don't await
|
processSyncQueue(); // Don't await
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Start polling
|
// 3. Start polling
|
||||||
startPolling();
|
startPolling();
|
||||||
} else {
|
} else {
|
||||||
// Server side: Use data from load function directly
|
|
||||||
if (isMounted) {
|
|
||||||
localListStore.set(data.list);
|
localListStore.set(data.list);
|
||||||
initializePollingStatus(data.list);
|
initializePollingStatus(data.list);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
|
||||||
stopPolling();
|
stopPolling();
|
||||||
clearTimeout(itemErrorTimeout);
|
clearTimeout(itemErrorTimeout);
|
||||||
};
|
};
|
||||||
@ -124,15 +124,108 @@
|
|||||||
|
|
||||||
// Helper to fetch from API and update DB + Store
|
// Helper to fetch from API and update DB + Store
|
||||||
async function fetchAndUpdateList(listId: number) {
|
async function fetchAndUpdateList(listId: number) {
|
||||||
isRefreshing = true;
|
// Don't trigger multiple refreshes concurrently
|
||||||
|
if (isRefreshing) return;
|
||||||
|
|
||||||
|
isRefreshing = true; // Show refresh indicator
|
||||||
|
console.log('List Detail: Fetching fresh data for list', listId);
|
||||||
try {
|
try {
|
||||||
|
// Fetch the entire list detail (including items) from the API
|
||||||
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
||||||
await putListToDb(freshList); // Update IndexedDB
|
|
||||||
localListStore.set(freshList); // Update the UI store
|
// Update IndexedDB with the latest data
|
||||||
// No need to re-initialize polling status here, checkListStatus will update it
|
await putListToDb(freshList);
|
||||||
|
|
||||||
|
// Update the local Svelte store, which triggers UI updates
|
||||||
|
localListStore.set(freshList);
|
||||||
|
|
||||||
|
// Reset the polling status based on this fresh data
|
||||||
|
// (This ensures the next poll compares against the latest fetched state)
|
||||||
|
initializePollingStatus(freshList);
|
||||||
|
|
||||||
console.log('List Detail: Fetched and updated list', listId);
|
console.log('List Detail: Fetched and updated list', listId);
|
||||||
|
clearItemError(); // Clear any lingering item errors after a successful refresh
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('List Detail: Failed to fetch fresh list data', err);
|
console.error('List Detail: Failed to fetch fresh list data', err);
|
||||||
|
// Display an error message to the user via the existing error handling mechanism
|
||||||
|
handleItemUpdateError(
|
||||||
|
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
|
||||||
|
);
|
||||||
|
// Note: If the error was 401/403, the apiClient or layout guard should handle logout/redirect
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false; // Hide refresh indicator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Polling Logic ---
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling();
|
||||||
|
const currentList = get(localListStore);
|
||||||
|
if (!currentList) return;
|
||||||
|
console.log(
|
||||||
|
`Polling: Starting polling for list ${currentList.id} every ${POLLING_INTERVAL_MS}ms`
|
||||||
|
);
|
||||||
|
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollIntervalId) {
|
||||||
|
clearInterval(pollIntervalId);
|
||||||
|
pollIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkListStatus() {
|
||||||
|
const currentList = get(localListStore);
|
||||||
|
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) {
|
||||||
|
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Polling: Checking status for list ${currentList.id}`);
|
||||||
|
try {
|
||||||
|
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
|
||||||
|
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
|
||||||
|
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
|
||||||
|
? new Date(currentStatus.latest_item_updated_at)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const listChanged =
|
||||||
|
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
|
||||||
|
const itemsChanged =
|
||||||
|
currentLatestItemUpdatedAt?.getTime() !==
|
||||||
|
lastKnownStatus.latest_item_updated_at?.getTime() ||
|
||||||
|
currentStatus.item_count !== lastKnownStatus.item_count;
|
||||||
|
|
||||||
|
if (listChanged || itemsChanged) {
|
||||||
|
console.log('Polling: Change detected!', { listChanged, itemsChanged });
|
||||||
|
await refreshListData();
|
||||||
|
// Update known status AFTER successful refresh
|
||||||
|
lastKnownStatus = {
|
||||||
|
list_updated_at: currentListUpdatedAt,
|
||||||
|
latest_item_updated_at: currentLatestItemUpdatedAt,
|
||||||
|
item_count: currentStatus.item_count
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.log('Polling: No changes detected.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Polling: Failed to fetch list status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshListData() {
|
||||||
|
const listId = get(localListStore)?.id;
|
||||||
|
if (!listId) return;
|
||||||
|
isRefreshing = true;
|
||||||
|
console.log(`Polling: Refreshing full data for list ${listId}`);
|
||||||
|
try {
|
||||||
|
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
||||||
|
await putListToDb(freshList);
|
||||||
|
localListStore.set(freshList);
|
||||||
|
// No need to re-init polling status here, checkListStatus updates it after refresh
|
||||||
|
console.log('Polling: List data refreshed successfully.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Polling: Failed to refresh list data for ${listId}:`, err);
|
||||||
handleItemUpdateError(
|
handleItemUpdateError(
|
||||||
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
|
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
|
||||||
);
|
);
|
||||||
@ -141,7 +234,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to initialize polling status from ListDetail data
|
|
||||||
function initializePollingStatus(listData: ListDetail | null) {
|
function initializePollingStatus(listData: ListDetail | null) {
|
||||||
if (!listData) {
|
if (!listData) {
|
||||||
lastKnownStatus = null;
|
lastKnownStatus = null;
|
||||||
@ -169,74 +261,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Polling Logic ---
|
|
||||||
function startPolling() {
|
|
||||||
stopPolling();
|
|
||||||
if (!$localListStore) return;
|
|
||||||
console.log(
|
|
||||||
`Polling: Starting polling for list ${$localListStore.id} every ${POLLING_INTERVAL_MS}ms`
|
|
||||||
);
|
|
||||||
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollIntervalId) {
|
|
||||||
clearInterval(pollIntervalId);
|
|
||||||
pollIntervalId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkListStatus() {
|
|
||||||
const currentList = get(localListStore); // Use get for non-reactive access inside async
|
|
||||||
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) {
|
|
||||||
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Polling: Checking status for list ${currentList.id}`);
|
|
||||||
try {
|
|
||||||
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
|
|
||||||
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
|
|
||||||
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
|
|
||||||
? new Date(currentStatus.latest_item_updated_at)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const listChanged =
|
|
||||||
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
|
|
||||||
const itemsChanged =
|
|
||||||
currentLatestItemUpdatedAt?.getTime() !==
|
|
||||||
lastKnownStatus.latest_item_updated_at?.getTime() ||
|
|
||||||
currentStatus.item_count !== lastKnownStatus.item_count;
|
|
||||||
|
|
||||||
if (listChanged || itemsChanged) {
|
|
||||||
console.log('Polling: Change detected!', { listChanged, itemsChanged });
|
|
||||||
await refreshListData(); // Fetch full data
|
|
||||||
// Update known status AFTER successful refresh
|
|
||||||
lastKnownStatus = {
|
|
||||||
list_updated_at: currentListUpdatedAt,
|
|
||||||
latest_item_updated_at: currentLatestItemUpdatedAt,
|
|
||||||
item_count: currentStatus.item_count
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
console.log('Polling: No changes detected.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Polling: Failed to fetch list status:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshListData() {
|
|
||||||
// Refactored to use store value
|
|
||||||
const listId = get(localListStore)?.id;
|
|
||||||
if (listId) {
|
|
||||||
await fetchAndUpdateList(listId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Event Handlers from ItemDisplay ---
|
// --- Event Handlers from ItemDisplay ---
|
||||||
async function handleItemUpdated(event: CustomEvent<ItemPublic>) {
|
/** Handles the itemUpdated event from ItemDisplay */
|
||||||
|
function handleItemUpdated(event: CustomEvent<ItemPublic>) {
|
||||||
const updatedItem = event.detail;
|
const updatedItem = event.detail;
|
||||||
console.log('Parent received itemUpdated:', updatedItem);
|
console.log('Parent received itemUpdated:', updatedItem);
|
||||||
// Update DB (already done in ItemDisplay optimistic update)
|
|
||||||
// Update store for UI
|
// Update store for UI
|
||||||
localListStore.update((currentList) => {
|
localListStore.update((currentList) => {
|
||||||
if (!currentList) return null;
|
if (!currentList) return null;
|
||||||
@ -244,15 +273,15 @@
|
|||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
currentList.items[index] = updatedItem;
|
currentList.items[index] = updatedItem;
|
||||||
}
|
}
|
||||||
return { ...currentList, items: [...currentList.items] };
|
return { ...currentList, items: [...currentList.items] }; // Return new object
|
||||||
});
|
});
|
||||||
clearItemError();
|
clearItemError();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleItemDeleted(event: CustomEvent<number>) {
|
/** Handles the itemDeleted event from ItemDisplay */
|
||||||
|
function handleItemDeleted(event: CustomEvent<number>) {
|
||||||
const deletedItemId = event.detail;
|
const deletedItemId = event.detail;
|
||||||
console.log('Parent received itemDeleted:', deletedItemId);
|
console.log('Parent received itemDeleted:', deletedItemId);
|
||||||
// Update DB (already done in ItemDisplay optimistic update)
|
|
||||||
// Update store for UI
|
// Update store for UI
|
||||||
localListStore.update((currentList) => {
|
localListStore.update((currentList) => {
|
||||||
if (!currentList) return null;
|
if (!currentList) return null;
|
||||||
@ -264,33 +293,48 @@
|
|||||||
clearItemError();
|
clearItemError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handles the updateError event from ItemDisplay */
|
||||||
function handleItemUpdateError(event: CustomEvent<string>) {
|
function handleItemUpdateError(event: CustomEvent<string>) {
|
||||||
/* ... (keep existing) ... */
|
const errorMsg = event.detail;
|
||||||
}
|
console.log('Parent received updateError:', errorMsg);
|
||||||
function clearItemError() {
|
itemUpdateError = errorMsg;
|
||||||
/* ... (keep existing) ... */
|
clearTimeout(itemErrorTimeout);
|
||||||
|
itemErrorTimeout = setTimeout(() => {
|
||||||
|
itemUpdateError = null;
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Add Item Logic ---
|
/** Clears the general item update error message */
|
||||||
|
function clearItemError() {
|
||||||
|
itemUpdateError = null;
|
||||||
|
clearTimeout(itemErrorTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add Item Logic (Single Item) ---
|
||||||
|
/** Handles submission of the Add Item form */
|
||||||
async function handleAddItem() {
|
async function handleAddItem() {
|
||||||
const currentList = get(localListStore); // Use get for non-reactive access
|
const currentList = get(localListStore);
|
||||||
if (!newItemName.trim() || !currentList) return;
|
if (!newItemName.trim() || !currentList) {
|
||||||
|
addItemError = 'Item name cannot be empty.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isAddingItem) return;
|
if (isAddingItem) return;
|
||||||
|
|
||||||
isAddingItem = true;
|
isAddingItem = true;
|
||||||
addItemError = null;
|
addItemError = null;
|
||||||
clearItemError();
|
clearItemError();
|
||||||
|
|
||||||
// 1. Optimistic UI Update with Temporary ID (Using negative random number for simplicity)
|
// 1. Optimistic UI Update with Temporary ID
|
||||||
const tempId = Math.floor(Math.random() * -1000000);
|
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
const currentUserId = get(authStore).user?.id; // Get current user ID synchronously
|
const currentUserId = get(authStore).user?.id;
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
addItemError = 'Cannot add item: User not identified.';
|
addItemError = 'Cannot add item: User not identified.';
|
||||||
isAddingItem = false;
|
isAddingItem = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const optimisticItem: ItemPublic = {
|
const optimisticItem: ItemPublic = {
|
||||||
id: tempId, // Use temporary ID
|
// Use temporary string ID for optimistic UI
|
||||||
|
id: tempId as any, // Cast needed as DB expects number, but temp is string
|
||||||
list_id: currentList.id,
|
list_id: currentList.id,
|
||||||
name: newItemName.trim(),
|
name: newItemName.trim(),
|
||||||
quantity: newItemQuantity.trim() || null,
|
quantity: newItemQuantity.trim() || null,
|
||||||
@ -305,8 +349,7 @@
|
|||||||
localListStore.update((list) =>
|
localListStore.update((list) =>
|
||||||
list ? { ...list, items: [...list.items, optimisticItem] } : null
|
list ? { ...list, items: [...list.items, optimisticItem] } : null
|
||||||
);
|
);
|
||||||
// Note: Cannot add item with temp ID to IndexedDB if keyPath is 'id' and type is number.
|
// Skip adding temp item to IndexedDB for simplicity in MVP
|
||||||
// For MVP, we skip adding temp items to DB and rely on sync + refresh.
|
|
||||||
|
|
||||||
// 2. Queue Sync Action
|
// 2. Queue Sync Action
|
||||||
const actionPayload: ItemCreate = {
|
const actionPayload: ItemCreate = {
|
||||||
@ -317,8 +360,8 @@
|
|||||||
await addSyncAction({
|
await addSyncAction({
|
||||||
type: 'create_item',
|
type: 'create_item',
|
||||||
payload: { listId: currentList.id, data: actionPayload },
|
payload: { listId: currentList.id, data: actionPayload },
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
// tempId: tempId // Optional: include tempId for mapping later
|
tempId: tempId // Include tempId for potential mapping later
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Trigger sync if online
|
// 3. Trigger sync if online
|
||||||
@ -330,32 +373,170 @@
|
|||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.error('Failed to queue add item action:', dbError);
|
console.error('Failed to queue add item action:', dbError);
|
||||||
addItemError = 'Failed to save item for offline sync.';
|
addItemError = 'Failed to save item for offline sync.';
|
||||||
// Revert optimistic UI update? More complex.
|
// Revert optimistic UI update
|
||||||
localListStore.update((list) =>
|
localListStore.update((list) =>
|
||||||
list ? { ...list, items: list.items.filter((i) => i.id !== tempId) } : null
|
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
isAddingItem = false;
|
isAddingItem = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- OCR Handling ---
|
||||||
|
function openOcrModal() {
|
||||||
|
ocrError = null;
|
||||||
|
confirmOcrError = null;
|
||||||
|
showOcrModal = true;
|
||||||
|
}
|
||||||
|
function closeOcrModal() {
|
||||||
|
showOcrModal = false;
|
||||||
|
}
|
||||||
|
function closeOcrReview() {
|
||||||
|
showOcrReview = false;
|
||||||
|
ocrResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles image selection from the modal, uploads it, and shows review modal */
|
||||||
|
async function handleImageSelected(event: CustomEvent<File>) {
|
||||||
|
const imageFile = event.detail;
|
||||||
|
closeOcrModal();
|
||||||
|
isProcessingOcr = true;
|
||||||
|
ocrError = null;
|
||||||
|
confirmOcrError = null;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image_file', imageFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.post<OcrExtractResponse>('/v1/ocr/extract-items', formData);
|
||||||
|
console.log('OCR Extraction successful:', result);
|
||||||
|
if (result.extracted_items && result.extracted_items.length > 0) {
|
||||||
|
ocrResults = result.extracted_items;
|
||||||
|
showOcrReview = true; // Show the review modal
|
||||||
|
} else {
|
||||||
|
ocrError = 'OCR processing finished, but no items were extracted.';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('OCR failed:', err);
|
||||||
|
if (err instanceof ApiClientError) {
|
||||||
|
let detail = 'Failed to process image for items.';
|
||||||
|
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
||||||
|
detail = (err.errorData as { detail: string }).detail;
|
||||||
|
}
|
||||||
|
if (err.status === 413) {
|
||||||
|
detail = `Image file too large.`;
|
||||||
|
}
|
||||||
|
if (err.status === 400) {
|
||||||
|
detail = `Invalid image file type or request.`;
|
||||||
|
}
|
||||||
|
ocrError = `OCR Error (${err.status}): ${detail}`;
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
ocrError = `OCR Network/Client Error: ${err.message}`;
|
||||||
|
} else {
|
||||||
|
ocrError = 'An unexpected OCR error occurred.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isProcessingOcr = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles confirmation from the OCR Review modal */
|
||||||
|
async function handleOcrConfirm(event: CustomEvent<string[]>) {
|
||||||
|
const itemNamesToAdd = event.detail;
|
||||||
|
closeOcrReview();
|
||||||
|
|
||||||
|
if (!itemNamesToAdd || itemNamesToAdd.length === 0) {
|
||||||
|
console.log('OCR Confirm: No items selected to add.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfirmingOcrItems = true;
|
||||||
|
confirmOcrError = null;
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const currentList = get(localListStore); // Get current list state
|
||||||
|
const currentUserId = get(authStore).user?.id;
|
||||||
|
|
||||||
|
if (!currentList || !currentUserId) {
|
||||||
|
confirmOcrError = 'Cannot add items: list or user data missing.';
|
||||||
|
isConfirmingOcrItems = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OCR Confirm: Attempting to add ${itemNamesToAdd.length} items...`);
|
||||||
|
|
||||||
|
// Process items sequentially for clearer feedback/error handling in MVP
|
||||||
|
for (const name of itemNamesToAdd) {
|
||||||
|
if (!name.trim()) continue; // Skip empty names
|
||||||
|
|
||||||
|
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
// Optimistic UI update
|
||||||
|
const optimisticItem: ItemPublic = {
|
||||||
|
id: tempId as any,
|
||||||
|
list_id: currentList.id,
|
||||||
|
name: name.trim(),
|
||||||
|
quantity: null,
|
||||||
|
is_complete: false,
|
||||||
|
price: null,
|
||||||
|
added_by_id: currentUserId,
|
||||||
|
completed_by_id: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
localListStore.update((list) =>
|
||||||
|
list ? { ...list, items: [...list.items, optimisticItem] } : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Queue Sync Action
|
||||||
|
const actionPayload: ItemCreate = { name: name.trim(), quantity: undefined };
|
||||||
|
try {
|
||||||
|
await addSyncAction({
|
||||||
|
type: 'create_item',
|
||||||
|
payload: { listId: currentList.id, data: actionPayload },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
tempId: tempId
|
||||||
|
});
|
||||||
|
successCount++;
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error(`Failed to queue item '${name}':`, dbError);
|
||||||
|
failCount++;
|
||||||
|
// Revert optimistic UI update for this specific item
|
||||||
|
localListStore.update((list) =>
|
||||||
|
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger sync if online
|
||||||
|
if (browser && navigator.onLine) processSyncQueue();
|
||||||
|
|
||||||
|
isConfirmingOcrItems = false;
|
||||||
|
|
||||||
|
// Provide feedback
|
||||||
|
if (failCount > 0) {
|
||||||
|
confirmOcrError = `Added ${successCount} items. Failed to queue ${failCount} items for sync.`;
|
||||||
|
} else {
|
||||||
|
console.log(`Successfully queued ${successCount} items from OCR.`);
|
||||||
|
// Optionally show a temporary success toast/message
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Template -->
|
<!-- Template -->
|
||||||
{#if $localListStore}
|
{#if $localListStore}
|
||||||
{@const list = $localListStore}
|
{@const list = $localListStore}
|
||||||
<!-- Create local const for easier access -->
|
<!-- Create local const for easier access in template -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Sync Status Indicator -->
|
<!-- Sync Status Indicator -->
|
||||||
{#if $syncStatus === 'syncing'}
|
{#if $syncStatus === 'syncing'}
|
||||||
<div
|
<div
|
||||||
class="fixed right-4 bottom-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
|
class="fixed bottom-4 right-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
Syncing changes...
|
Syncing changes...
|
||||||
</div>
|
</div>
|
||||||
{:else if $syncStatus === 'error' && $syncError}
|
{:else if $syncStatus === 'error' && $syncError}
|
||||||
<div
|
<div
|
||||||
class="fixed right-4 bottom-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
|
class="fixed bottom-4 right-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
Sync Error: {$syncError}
|
Sync Error: {$syncError}
|
||||||
@ -381,20 +562,78 @@
|
|||||||
).toLocaleString()}
|
).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0 space-x-2">
|
<div class="flex flex-shrink-0 items-center space-x-2">
|
||||||
|
<!-- Action Buttons -->
|
||||||
{#if isRefreshing}
|
{#if isRefreshing}
|
||||||
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
|
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- OCR Button with Progress Indication -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={openOcrModal}
|
||||||
|
disabled={isProcessingOcr || isConfirmingOcrItems}
|
||||||
|
class="inline-flex items-center rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if isProcessingOcr}
|
||||||
|
<svg
|
||||||
|
class="mr-2 h-4 w-4 animate-spin text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
><circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle><path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path></svg
|
||||||
|
>
|
||||||
|
Processing...
|
||||||
|
{:else if isConfirmingOcrItems}
|
||||||
|
<svg
|
||||||
|
class="mr-2 h-4 w-4 animate-spin text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
><circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle><path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path></svg
|
||||||
|
>
|
||||||
|
Adding Items...
|
||||||
|
{:else}
|
||||||
|
📷 Add via Photo
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
href="/lists/{list.id}/edit"
|
href="/lists/{list.id}/edit"
|
||||||
class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2 focus:outline-none"
|
class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Edit List Details
|
Edit List Details
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if ocrError || confirmOcrError}
|
||||||
|
<!-- Display OCR/Confirm errors -->
|
||||||
|
<div class="rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" role="alert">
|
||||||
|
{ocrError || confirmOcrError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Add New Item Form -->
|
<!-- Add New Item Form Section -->
|
||||||
<div class="rounded bg-white p-4 shadow">
|
<div class="rounded bg-white p-4 shadow">
|
||||||
<h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2>
|
<h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2>
|
||||||
<form
|
<form
|
||||||
@ -409,7 +648,7 @@
|
|||||||
placeholder="Item name (required)"
|
placeholder="Item name (required)"
|
||||||
required
|
required
|
||||||
bind:value={newItemName}
|
bind:value={newItemName}
|
||||||
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70"
|
||||||
disabled={isAddingItem}
|
disabled={isAddingItem}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -420,13 +659,13 @@
|
|||||||
id="new-item-qty"
|
id="new-item-qty"
|
||||||
placeholder="Quantity (opt.)"
|
placeholder="Quantity (opt.)"
|
||||||
bind:value={newItemQuantity}
|
bind:value={newItemQuantity}
|
||||||
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70"
|
||||||
disabled={isAddingItem}
|
disabled={isAddingItem}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white shadow-sm transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
class="whitespace-nowrap rounded bg-blue-600 px-4 py-2 font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={isAddingItem}
|
disabled={isAddingItem}
|
||||||
>
|
>
|
||||||
{isAddingItem ? 'Adding...' : 'Add Item'}
|
{isAddingItem ? 'Adding...' : 'Add Item'}
|
||||||
@ -441,6 +680,7 @@
|
|||||||
<div class="rounded bg-white p-6 shadow">
|
<div class="rounded bg-white p-6 shadow">
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2>
|
||||||
{#if itemUpdateError}
|
{#if itemUpdateError}
|
||||||
|
<!-- Display errors bubbled up from items -->
|
||||||
<div
|
<div
|
||||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
|
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
|
||||||
role="alert"
|
role="alert"
|
||||||
@ -450,6 +690,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if list.items && list.items.length > 0}
|
{#if list.items && list.items.length > 0}
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
|
<!-- Use {#key} block to help Svelte efficiently update the list when items are added/removed/reordered -->
|
||||||
{#each list.items as item (item.id)}
|
{#each list.items as item (item.id)}
|
||||||
<ItemDisplay
|
<ItemDisplay
|
||||||
{item}
|
{item}
|
||||||
@ -473,3 +714,18 @@
|
|||||||
<!-- Fallback if list data is somehow null/undefined after load function -->
|
<!-- Fallback if list data is somehow null/undefined after load function -->
|
||||||
<p class="text-center text-gray-500">Loading list data...</p>
|
<p class="text-center text-gray-500">Loading list data...</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- OCR Input Modal -->
|
||||||
|
{#if showOcrModal}
|
||||||
|
<ImageOcrInput on:imageSelected={handleImageSelected} on:cancel={closeOcrModal} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- OCR Review Modal -->
|
||||||
|
{#if showOcrReview}
|
||||||
|
<OcrReview
|
||||||
|
initialItems={ocrResults}
|
||||||
|
on:confirm={handleOcrConfirm}
|
||||||
|
on:cancel={closeOcrReview}
|
||||||
|
bind:isLoading={isConfirmingOcrItems}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
Loading…
Reference in New Issue
Block a user