From c1922df9f3926524d54d9f2a1a8f7089ffd8c94b Mon Sep 17 00:00:00 2001 From: Serhii Date: Mon, 16 Sep 2024 19:46:57 +0300 Subject: [PATCH] features_toasty_and_delete_items --- frontend/src/api/api.ts | 10 +++- frontend/src/api/auth.ts | 1 - frontend/src/components/UploadContent.tsx | 10 ++-- frontend/src/components/card.tsx | 19 +++++- frontend/src/components/image.tsx | 51 +++++++++++++---- frontend/src/hooks/alerts.tsx | 4 +- frontend/src/pages/Collection.tsx | 68 +++++++++++++++++++--- frontend/src/pages/Collections.tsx | 70 +++++++++++++++++++---- frontend/src/pages/Login.tsx | 12 ++++ linguaphoto/api/collection.py | 11 ++++ linguaphoto/api/image.py | 23 +++++++- linguaphoto/crud/base.py | 38 ++++++++++++ linguaphoto/crud/collection.py | 6 +- linguaphoto/crud/image.py | 7 +++ 14 files changed, 285 insertions(+), 45 deletions(-) diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 2b1e631..b4c3e4b 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -42,10 +42,18 @@ export class Api { await this.api.post(`/edit_collection`, data); return null; } - public async getAllCollections(): Promise | null> { + public async getAllCollections(): Promise | []> { const response = await this.api.get(`/get_collections`); return response.data; } + public async deleteCollection(collection_id: string): Promise { + await this.api.get(`/delete_collection?id=${collection_id}`); + return null; + } + public async deleteImage(image_id: string): Promise { + await this.api.get(`/delete_image?id=${image_id}`); + return null; + } public async uploadImage(file: File, collection_id: string): Promise { const response = await this.api.post("/upload", { file, diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 50a7f48..4c86fa3 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -22,7 +22,6 @@ export const read_me = async (token: string): Promise => { }; export const signin = async (data: SigninData): Promise => { const response = await axios.post(`${API_URL}/signin`, data); - console.log(response); return response.data; }; export const social_facebook_login = async ( diff --git a/frontend/src/components/UploadContent.tsx b/frontend/src/components/UploadContent.tsx index 4b4dc4f..7dfd505 100644 --- a/frontend/src/components/UploadContent.tsx +++ b/frontend/src/components/UploadContent.tsx @@ -1,7 +1,7 @@ +import { useAlertQueue } from "hooks/alerts"; import { FC, useState } from "react"; import { XCircleFill } from "react-bootstrap-icons"; import ImageUploading, { ImageListType } from "react-images-uploading"; - interface UploadContentProps { onUpload: (file: File) => Promise; // Updated to handle a single file } @@ -9,7 +9,7 @@ interface UploadContentProps { const UploadContent: FC = ({ onUpload }) => { const [images, setImages] = useState([]); const [uploading, setUploading] = useState(false); - // const [uploadIndex, setUploadIndex] = useState(0); + const { addAlert } = useAlertQueue(); const maxNumber = 10; // Set the maximum number of files allowed @@ -21,9 +21,11 @@ const UploadContent: FC = ({ onUpload }) => { const file = images[i].file as File; try { await onUpload(file); // Upload each file one by one - // Optionally, handle success feedback here + if (i == images.length - 1) + addAlert(`${i + 1} images has been uploaded!`, "success"); } catch (error) { - console.error(`Failed to upload file ${file.name}:`, error); + addAlert(`${file.name} has been failed to upload. ${error}`, "error"); + break; // Optionally, handle failure feedback here } } diff --git a/frontend/src/components/card.tsx b/frontend/src/components/card.tsx index 71116af..dd4e07d 100644 --- a/frontend/src/components/card.tsx +++ b/frontend/src/components/card.tsx @@ -1,8 +1,11 @@ +import { TrashFill } from "react-bootstrap-icons"; import { useNavigate } from "react-router-dom"; import { Collection } from "types/model"; - -const CardItem: React.FC = (collectionProps) => { - const { id, title, description, images } = collectionProps; +interface CardItemProps extends Collection { + onDelete: (id: string) => void; +} +const CardItem: React.FC = (cardprops) => { + const { id, title, description, images, onDelete } = cardprops; const navigate = useNavigate(); const handleOpen = () => { @@ -12,6 +15,9 @@ const CardItem: React.FC = (collectionProps) => { const handleEdit = () => { navigate(`/collection/${id}?Action=edit`); }; + const handleDelete = () => { + onDelete(id); + }; return (
@@ -23,6 +29,7 @@ const CardItem: React.FC = (collectionProps) => {

{images.length} Images

+
+
diff --git a/frontend/src/components/image.tsx b/frontend/src/components/image.tsx index 5e6c8d6..a28aacd 100644 --- a/frontend/src/components/image.tsx +++ b/frontend/src/components/image.tsx @@ -1,9 +1,15 @@ import React from "react"; -import { CheckCircleFill, LockFill, PencilFill } from "react-bootstrap-icons"; +import { + CheckCircleFill, + LockFill, + PencilFill, + TrashFill, +} from "react-bootstrap-icons"; import { Image } from "types/model"; // Extend the existing Image interface to include the new function interface ImageWithFunction extends Image { handleTranslateOneImage: (image_id: string) => void; + showDeleteModal: (id: string) => void; } const ImageComponent: React.FC = ({ id, @@ -11,6 +17,7 @@ const ImageComponent: React.FC = ({ image_url, transcriptions, handleTranslateOneImage, + showDeleteModal, }) => { return (
= ({ {/* Centered Edit Button */}
{is_translated ? ( - +
+ + +
) : ( - +
+ + +
)}
diff --git a/frontend/src/hooks/alerts.tsx b/frontend/src/hooks/alerts.tsx index 75deeb3..84e8ec1 100644 --- a/frontend/src/hooks/alerts.tsx +++ b/frontend/src/hooks/alerts.tsx @@ -110,9 +110,9 @@ export const AlertQueue = (props: AlertQueueProps) => { <> {children} {Array.from(alerts).map(([alertId, [alert, kind]]) => { return ( diff --git a/frontend/src/pages/Collection.tsx b/frontend/src/pages/Collection.tsx index 14062ae..d9d8021 100644 --- a/frontend/src/pages/Collection.tsx +++ b/frontend/src/pages/Collection.tsx @@ -5,6 +5,7 @@ import Modal from "components/modal"; import UploadContent from "components/UploadContent"; import { useAuth } from "contexts/AuthContext"; import { useLoading } from "contexts/LoadingContext"; +import { useAlertQueue } from "hooks/alerts"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { Col, Row } from "react-bootstrap"; import { @@ -29,10 +30,12 @@ const CollectionPage: React.FC = () => { const [collection, setCollection] = useState(null); const { auth, is_auth } = useAuth(); const { startLoading, stopLoading } = useLoading(); - const [showModal, setShowModal] = useState(false); + const [showUploadModal, setShowUploadModal] = useState(false); + const [showDeleteImageModal, setShowDeleteImageModal] = useState(false); const [images, setImages] = useState | null>([]); const audioRef = useRef(null); - + const { addAlert } = useAlertQueue(); + const [deleteImageIndex, setDeleteImageIndex] = useState(""); const apiClient: AxiosInstance = useMemo( () => axios.create({ @@ -101,7 +104,7 @@ const CollectionPage: React.FC = () => { }; asyncfunction(); } - }, [collection]); + }, [collection?.id]); useEffect(() => { if (audioRef.current) { audioRef.current.load(); @@ -111,8 +114,10 @@ const CollectionPage: React.FC = () => { e.preventDefault(); startLoading(); const collection = await API.createCollection({ title, description }); - if (collection != null) + if (collection != null) { navigate(`/collection/${collection.id}?Action=edit`); + addAlert("New collection has been created successfully!", "success"); + } else addAlert("The process has gone wrong!", "error"); stopLoading(); }; // Navigate between images @@ -155,6 +160,7 @@ const CollectionPage: React.FC = () => { const asyncfunction = async () => { startLoading(); await API.editCollection(collection); + addAlert("The collection has been updated successfully!", "success"); stopLoading(); }; asyncfunction(); @@ -175,10 +181,32 @@ const CollectionPage: React.FC = () => { const handleTranslateOneImage = async (image_id: string) => { if (images) { startLoading(); + addAlert( + "The image is being tranlated. Please wait a moment.", + "primary", + ); const image_response = await API.translateImages([image_id]); const i = images?.findIndex((image) => image.id == image_id); images[i] = image_response[0]; setImages([...images]); + addAlert("The image has been tranlated!", "success"); + stopLoading(); + } + }; + const onShowDeleteImageModal = (id: string) => { + setDeleteImageIndex(id); + setShowDeleteImageModal(true); + }; + const onDeleteImage = async () => { + if (deleteImageIndex) { + startLoading(); + await API.deleteImage(deleteImageIndex); + if (images) { + const filter = images.filter((image) => image.id != deleteImageIndex); + setImages(filter); + } + setShowDeleteImageModal(false); + addAlert("The image has been deleted!", "success"); stopLoading(); } }; @@ -198,7 +226,7 @@ const CollectionPage: React.FC = () => {

New Collection

{
{/* Upload Modal */} - setShowModal(false)}> + setShowUploadModal(false)} + >
+ setShowDeleteImageModal(false)} + > +
+ Are you sure you want to delete the collection? + + +
+
{images ? ( images.map((image) => { @@ -295,6 +346,7 @@ const CollectionPage: React.FC = () => { ); diff --git a/frontend/src/pages/Collections.tsx b/frontend/src/pages/Collections.tsx index 8e26ff0..43d6f25 100644 --- a/frontend/src/pages/Collections.tsx +++ b/frontend/src/pages/Collections.tsx @@ -1,17 +1,41 @@ import { Api } from "api/api"; import axios, { AxiosInstance } from "axios"; import CardItem from "components/card"; +import Modal from "components/modal"; import NewCardItem from "components/new_card"; import { useAuth } from "contexts/AuthContext"; import { useLoading } from "contexts/LoadingContext"; -import { useEffect, useState } from "react"; +import { useAlertQueue } from "hooks/alerts"; +import { useEffect, useMemo, useState } from "react"; import { Col, Row } from "react-bootstrap"; import { Collection } from "types/model"; const Collections = () => { - const [collections, setCollection] = useState | null>(null); + const [collections, setCollection] = useState | []>([]); const { is_auth, auth } = useAuth(); + const [showModal, setShowModal] = useState(false); const { startLoading, stopLoading } = useLoading(); + const [delete_ID, setDeleteID] = useState(String); + const { addAlert } = useAlertQueue(); + + const onDeleteModalShow = (id: string) => { + setDeleteID(id); + setShowModal(true); + }; + const onDelete = async () => { + if (delete_ID) { + startLoading(); + await API.deleteCollection(delete_ID); + const filter = collections?.filter( + (collection) => collection.id != delete_ID, + ); + setShowModal(false); + setCollection(filter); + addAlert("The Collection has been deleted.", "success"); + stopLoading(); + } + }; + useEffect(() => { if (is_auth) { const asyncfunction = async () => { @@ -24,15 +48,19 @@ const Collections = () => { } }, [is_auth]); - const apiClient: AxiosInstance = axios.create({ - baseURL: process.env.REACT_APP_BACKEND_URL, // Base URL for all requests - timeout: 10000, // Request timeout (in milliseconds) - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${auth?.token}`, // Add any default headers you need - }, - }); - const API = new Api(apiClient); + const apiClient: AxiosInstance = useMemo( + () => + axios.create({ + baseURL: process.env.REACT_APP_BACKEND_URL, // Base URL for all requests + timeout: 10000, // Request timeout (in milliseconds) + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth?.token}`, // Add any default headers you need + }, + }), + [auth?.token], + ); + const API = useMemo(() => new Api(apiClient), [apiClient]); return (
@@ -44,11 +72,29 @@ const Collections = () => { {collections?.map((collection) => { return ( - + ); })} + {/* Delete Modal */} + setShowModal(false)}> +
+ Are you sure you want to delete the collection? + + +
+
); }; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 8ce4141..cddf805 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,6 +1,7 @@ import { signin, signup } from "api/auth"; import { useAuth } from "contexts/AuthContext"; import { useLoading } from "contexts/LoadingContext"; +import { useAlertQueue } from "hooks/alerts"; import React, { useEffect, useState } from "react"; import { Google } from "react-bootstrap-icons"; import { useNavigate } from "react-router-dom"; @@ -12,6 +13,7 @@ const LoginPage: React.FC = () => { const { startLoading, stopLoading } = useLoading(); const { is_auth, setAuth } = useAuth(); const navigate = useNavigate(); + const { addAlert } = useAlertQueue(); useEffect(() => { if (is_auth) navigate("/collections"); }, [is_auth]); @@ -29,12 +31,22 @@ const LoginPage: React.FC = () => { startLoading(); const user = await signup({ email, password, username }); setAuth(user); + if (user) + addAlert("Welcome! You have been successfully signed up!", "success"); + else + addAlert( + "Sorry. The email or password have been exist already!", + "error", + ); stopLoading(); } else { // You can call your API for login startLoading(); const user = await signin({ email, password }); setAuth(user); + if (user) + addAlert("Welcome! You have been successfully signed in!", "success"); + else addAlert("Sorry. The email or password are invalid.", "error"); stopLoading(); } }; diff --git a/linguaphoto/api/collection.py b/linguaphoto/api/collection.py index 3772511..18f60f7 100644 --- a/linguaphoto/api/collection.py +++ b/linguaphoto/api/collection.py @@ -63,3 +63,14 @@ async def editcollection( updates={"title": collection.title, "description": collection.description, "images": collection.images}, ) return + + +@router.get("/delete_collection") +async def deletecollection( + id: str, + user_id: str = Depends(get_current_user_id), + collection_crud: CollectionCrud = Depends(), +) -> None: + async with collection_crud: + await collection_crud.delete_collection(collection_id=id) + return diff --git a/linguaphoto/api/image.py b/linguaphoto/api/image.py index ca9619f..d41711f 100644 --- a/linguaphoto/api/image.py +++ b/linguaphoto/api/image.py @@ -2,8 +2,9 @@ from typing import Annotated, List +from crud.collection import CollectionCrud from crud.image import ImageCrud -from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from models import Image from pydantic import BaseModel from utils.auth import get_current_user_id @@ -38,6 +39,26 @@ async def get_images( return images +@router.get("/delete_image") +async def delete_image( + id: str, + user_id: str = Depends(get_current_user_id), + image_crud: ImageCrud = Depends(), + collection_crud: CollectionCrud = Depends(), +) -> None: + print(id) + async with image_crud: + image = await image_crud.get_image(id) + if image: + async with collection_crud: + collection = await collection_crud.get_collection(image.collection) + updated_images = list(filter(lambda image: image != id, collection.images)) + await collection_crud.edit_collection(image.collection, {"images": updated_images}) + await image_crud.delete_image(id) + return + raise HTTPException(status_code=400, detail="Image is invalid") + + @router.post("/translate", response_model=List[Image]) async def translate( data: TranslateFramgement, user_id: str = Depends(get_current_user_id), image_crud: ImageCrud = Depends() diff --git a/linguaphoto/crud/base.py b/linguaphoto/crud/base.py index 0b02030..3906c8a 100644 --- a/linguaphoto/crud/base.py +++ b/linguaphoto/crud/base.py @@ -14,7 +14,12 @@ from types_aiobotocore_s3.service_resource import S3ServiceResource T = TypeVar("T", bound=BaseModel) + TABLE_NAME = settings.dynamodb_table_name +DEFAULT_CHUNK_SIZE = 100 +DEFAULT_SCAN_LIMIT = 1000 +ITEMS_PER_PAGE = 12 + logger = logging.getLogger(__name__) @@ -163,6 +168,39 @@ async def _update_item( raise ValueError(f"Invalid update: {str(e)}") raise + async def _delete_item(self, item: BaseModel | str) -> None: + table = await self.db.Table(TABLE_NAME) + await table.delete_item(Key={"id": item if isinstance(item, str) else item.id}) + + async def _list_items( + self, + item_class: type[T], + expression_attribute_names: dict[str, str] | None = None, + expression_attribute_values: dict[str, Any] | None = None, + filter_expression: str | None = None, + offset: int | None = None, + limit: int = DEFAULT_SCAN_LIMIT, + ) -> list[T]: + table = await self.db.Table(TABLE_NAME) + + query_params = { + "IndexName": "type_index", + "KeyConditionExpression": Key("type").eq(item_class.__name__), + "Limit": limit, + } + + if expression_attribute_names: + query_params["ExpressionAttributeNames"] = expression_attribute_names + if expression_attribute_values: + query_params["ExpressionAttributeValues"] = expression_attribute_values + if filter_expression: + query_params["FilterExpression"] = filter_expression + if offset: + query_params["ExclusiveStartKey"] = {"id": offset} + + items = (await table.query(**query_params))["Items"] + return [self._validate_item(item, item_class) for item in items] + async def _upload_to_s3(self, file: BinaryIO, unique_filename: str) -> None: bucket = await self.s3.Bucket(settings.bucket_name) await bucket.upload_fileobj(file, f"uploads/{unique_filename}") diff --git a/linguaphoto/crud/collection.py b/linguaphoto/crud/collection.py index 9d44012..90d848d 100644 --- a/linguaphoto/crud/collection.py +++ b/linguaphoto/crud/collection.py @@ -20,6 +20,8 @@ async def get_collections(self, user_id: str) -> List[Collection]: collections = await self._get_items_from_secondary_index("user", user_id, Collection) return collections - async def edit_collection(self, id: str, user_id: str, updates: dict) -> None: - # TODO: confirm user have the permission to edit this collection. + async def edit_collection(self, id: str, updates: dict) -> None: await self._update_item(id, Collection, updates) + + async def delete_collection(self, collection_id: str) -> None: + await self._delete_item(collection_id) diff --git a/linguaphoto/crud/image.py b/linguaphoto/crud/image.py index 586138b..8dfa9c7 100644 --- a/linguaphoto/crud/image.py +++ b/linguaphoto/crud/image.py @@ -72,6 +72,13 @@ async def get_images(self, collection_id: str, user_id: str) -> List[Image]: images = await self._get_items_from_secondary_index("user", user_id, Image, Key("collection").eq(collection_id)) return images + async def get_image(self, image_id: str) -> Image: + image = await self._get_item(image_id, Image, True) + return image + + async def delete_image(self, image_id: str) -> None: + await self._delete_item(image_id) + # Translates the images to text and synthesizes audio for the transcriptions async def translate(self, images: List[str], user_id: str) -> List[Image]: image_instances = []