From 00ffa3cb7d655057a1548ae9b25c134835c9d495 Mon Sep 17 00:00:00 2001 From: Serhii Date: Mon, 9 Sep 2024 19:26:35 +0300 Subject: [PATCH] feature_image_upload --- frontend/package-lock.json | 14 +++ frontend/package.json | 1 + frontend/src/api/api.ts | 26 +++-- frontend/src/api/auth.ts | 18 ++-- frontend/src/components/UploadContent.tsx | 124 ++++++++++++++++++++++ frontend/src/components/modal.tsx | 33 ++++++ frontend/src/contexts/AuthContext.tsx | 9 +- frontend/src/pages/Collection.tsx | 122 ++++++++++++--------- frontend/src/pages/Collections.tsx | 4 +- frontend/src/pages/Test.tsx | 6 +- linguaphoto/api/image.py | 24 ++++- linguaphoto/crud/image.py | 20 +++- linguaphoto/models.py | 4 +- 13 files changed, 322 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/UploadContent.tsx create mode 100644 frontend/src/components/modal.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb57277..e1e5b76 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", "react-icons": "^5.3.0", + "react-images-uploading": "^3.1.7", "react-scripts": "5.0.1", "typescript": "^4.3.0", "web-vitals": "^2.1.4" @@ -18291,6 +18292,19 @@ "react": "*" } }, + "node_modules/react-images-uploading": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/react-images-uploading/-/react-images-uploading-3.1.7.tgz", + "integrity": "sha512-woET50eCezm645iIeP4gCoN7HjdR3T64UXC5l53yd+2vHFp+pwABH8Z/aAO5IXDeC1aP6doQ+K738L701zswAw==", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 33aa38d..11819e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", "react-icons": "^5.3.0", + "react-images-uploading": "^3.1.7", "react-scripts": "5.0.1", "typescript": "^4.3.0", "web-vitals": "^2.1.4" diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 1a81e78..5b46e48 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,16 +1,11 @@ import { AxiosInstance } from "axios"; -import { Collection } from "types/model"; - -export interface Image { - filename: string; - s3_url: string; -} +import { Collection, Image } from "types/model"; export interface CollectionCreateFragment { title: string; description: string; } -export class api { +export class Api { public api: AxiosInstance; constructor(api: AxiosInstance) { @@ -20,7 +15,7 @@ export class api { const response = await this.api.get(`/`); return response.data.message; } - public async handleUpload(formData: FormData): Promise { + public async handleUpload(formData: FormData): Promise { try { const response = await this.api.post("/upload/", formData, { headers: { @@ -30,7 +25,7 @@ export class api { return response.data; } catch (error) { console.error("Error uploading the file", error); - return { s3_url: "", filename: "" }; + return null; } } public async createCollection( @@ -51,4 +46,17 @@ export class api { const response = await this.api.get(`/get_collections`); return response.data; } + public async uploadImage(file: File, collection_id: string): Promise { + const response = await this.api.post("/upload", { + file, + id: collection_id, + }); + return response.data; + } + public async getImages(collection_id: string): Promise> { + const response = await this.api.get( + `/get_images?collection_id=${collection_id}`, + ); + return response.data; + } } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index ab433e0..50a7f48 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -8,13 +8,17 @@ export const signup = async (data: SignupData): Promise => { const response = await axios.post(`${API_URL}/signup`, data); return response.data; }; -export const read_me = async (token: string): Promise => { - const response = await axios.get(`${API_URL}/me`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - return response.data; +export const read_me = async (token: string): Promise => { + try { + const response = await axios.get(`${API_URL}/me`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return response.data; + } catch { + return null; + } }; export const signin = async (data: SigninData): Promise => { const response = await axios.post(`${API_URL}/signin`, data); diff --git a/frontend/src/components/UploadContent.tsx b/frontend/src/components/UploadContent.tsx new file mode 100644 index 0000000..4b4dc4f --- /dev/null +++ b/frontend/src/components/UploadContent.tsx @@ -0,0 +1,124 @@ +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 +} + +const UploadContent: FC = ({ onUpload }) => { + const [images, setImages] = useState([]); + const [uploading, setUploading] = useState(false); + // const [uploadIndex, setUploadIndex] = useState(0); + + const maxNumber = 10; // Set the maximum number of files allowed + + const handleUpload = async () => { + if (images.length === 0) return; + + setUploading(true); + for (let i = 0; i < images.length; i++) { + const file = images[i].file as File; + try { + await onUpload(file); // Upload each file one by one + // Optionally, handle success feedback here + } catch (error) { + console.error(`Failed to upload file ${file.name}:`, error); + // Optionally, handle failure feedback here + } + } + setImages([]); // Clear images after uploading + setUploading(false); + }; + + const onChange = (imageList: ImageListType) => { + setImages(imageList); + }; + + return ( +
+ + {({ + imageList, + onImageUpload, + onImageRemoveAll, + onImageRemove, + isDragging, + dragProps, + }) => ( +
+ {/* Dropzone Area */} +
+

+ Drag & drop images here, or click to select files +

+
+ + {/* Display uploaded images below the dropzone */} +
+ {imageList.length > 0 ? ( + imageList.map((image, index) => ( +
+ + +
+ )) + ) : ( +

+ No images uploaded yet. +

+ )} +
+ + {/* Optional: Remove all button */} + {imageList.length > 0 && ( +
+ {/* Upload Button */} + + +
+ )} +
+ )} +
+
+ ); +}; + +export default UploadContent; diff --git a/frontend/src/components/modal.tsx b/frontend/src/components/modal.tsx new file mode 100644 index 0000000..d5ef647 --- /dev/null +++ b/frontend/src/components/modal.tsx @@ -0,0 +1,33 @@ +import { FC, ReactNode } from "react"; +import { X } from "react-bootstrap-icons"; // Using react-bootstrap-icons for the close button + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; +} + +const Modal: FC = ({ isOpen, onClose, children }) => { + if (!isOpen) return null; + + return ( +
+ {/* Modal container */} +
+ {/* Close button in the top right corner */} + + + {/* Modal content */} +
{children}
+
+
+ ); +}; + +export default Modal; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index b3977d4..e3a02ed 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -30,9 +30,12 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { const token = localStorage.getItem("token"); if (token) { const fetch_data = async (token: string) => { - const response = await read_me(token); - console.log(response); - if (response) setAuth(response); + try { + const response = await read_me(token); + if (response) setAuth(response); + } catch { + return; + } }; fetch_data(token); } else signout(); diff --git a/frontend/src/pages/Collection.tsx b/frontend/src/pages/Collection.tsx index f859894..9f06707 100644 --- a/frontend/src/pages/Collection.tsx +++ b/frontend/src/pages/Collection.tsx @@ -1,6 +1,8 @@ -import { api } from "api/api"; +import { Api } from "api/api"; import axios, { AxiosInstance } from "axios"; import ImageComponent from "components/image"; +import Modal from "components/modal"; +import UploadContent from "components/UploadContent"; import { useAuth } from "contexts/AuthContext"; import { useLoading } from "contexts/LoadingContext"; import React, { useEffect, useState } from "react"; @@ -8,40 +10,6 @@ import { Col, Row } from "react-bootstrap"; import { ArrowLeft } from "react-bootstrap-icons"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { Collection, Image } from "types/model"; -// Truncated mock data - -const images: Array = [ - { - id: "img1", - is_translated: true, - collection: "12345", - image_url: - "https://d1muf25xaso8hp.cloudfront.net/https%3A%2F%2Ff1700c1ec57d7b74b2c6981cad44f0cc.cdn.bubble.io%2Ff1725391119469x565871415375944100%2Fbubble-1725391118796.jpg?w=1536&h=864&auto=compress&dpr=1&fit=max", - audio_url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", - transcript: - "多空布文小一共免村童國好北姊穿找帽員文但。也裏頁文寸圓力發瓜想走息已占,卜話國清品科抄英貓,爬課人孝。來鴨結裏工香今合荷平", - }, - { - id: "img2", - is_translated: false, - collection: "12345", - image_url: - "https://d1muf25xaso8hp.cloudfront.net/https%3A%2F%2Ff1700c1ec57d7b74b2c6981cad44f0cc.cdn.bubble.io%2Ff1725391122027x185936571789460320%2Fbubble-1725391118802.jpg?w=1536&h=864&auto=compress&dpr=1&fit=max", - audio_url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", - transcript: - "多空布文小一共免村童國好北姊穿找帽員文但。也裏頁文寸圓力發瓜想走息已占,卜話國清品科抄英貓,爬課人孝。來鴨結裏工香今合荷平", - }, - { - id: "img3", - is_translated: true, - collection: "12345", - image_url: - "https://d1muf25xaso8hp.cloudfront.net/https%3A%2F%2Ff1700c1ec57d7b74b2c6981cad44f0cc.cdn.bubble.io%2Ff1725391122027x185936571789460320%2Fbubble-1725391118802.jpg?w=1536&h=864&auto=compress&dpr=1&fit=max", - audio_url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", - transcript: - "多空布文小一共免村童國好北姊穿找帽員文但。也裏頁文寸圓力發瓜想走息已占,卜話國清品科抄英貓,爬課人孝。來鴨結裏工香今合荷平", - }, -]; const CollectionPage: React.FC = () => { const { id } = useParams<{ id?: string }>(); @@ -54,6 +22,8 @@ const CollectionPage: React.FC = () => { const [collection, setCollection] = useState(null); const { auth, is_auth } = useAuth(); const { startLoading, stopLoading } = useLoading(); + const [showModal, setShowModal] = useState(false); + const [images, setImages] = useState | null>([]); const apiClient: AxiosInstance = axios.create({ baseURL: process.env.REACT_APP_BACKEND_URL, // Base URL for all requests @@ -63,10 +33,22 @@ const CollectionPage: React.FC = () => { Authorization: `Bearer ${auth?.token}`, // Add any default headers you need }, }); - const API = new api(apiClient); + const apiClient1: AxiosInstance = axios.create({ + baseURL: process.env.REACT_APP_BACKEND_URL, // Base URL for all requests + timeout: 1000000, // Request timeout (in milliseconds) + headers: { + "Content-Type": "multipart/form-data", + Authorization: `Bearer ${auth?.token}`, // Add any default headers you need + }, + }); + const API = new Api(apiClient); + const API_Uploader = new Api(apiClient1); // Helper to check if it's an edit action const isEditAction = location.search.includes("Action=edit"); + // Get translated images + let translatedImages: Array = []; + // Simulate fetching data for the edit page (mocking API call) useEffect(() => { if (id && is_auth) { @@ -80,8 +62,10 @@ const CollectionPage: React.FC = () => { } }, [id, is_auth]); - // Get translated images - const translatedImages = images.filter((img) => img.is_translated); + useEffect(() => { + // Get translated images + if (images) translatedImages = images.filter((img) => img.is_translated); + }, [images]); useEffect(() => { if (translatedImages.length > 0) { @@ -89,6 +73,17 @@ const CollectionPage: React.FC = () => { } }, [currentImageIndex, translatedImages]); + useEffect(() => { + if (collection) { + const asyncfunction = async () => { + startLoading(); + const images = await API.getImages(collection.id); + setImages(images); + stopLoading(); + }; + asyncfunction(); + } + }, [collection]); const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); startLoading(); @@ -126,6 +121,18 @@ const CollectionPage: React.FC = () => { asyncfunction(); } }; + const handleUpload = async (file: File) => { + if (collection) { + startLoading(); + const Image = await API_Uploader.uploadImage(file, collection?.id); + stopLoading(); + if (Image) { + const new_images: Array | null = images; + new_images?.push(Image); + if (new_images != undefined) setImages(new_images); + } + } + }; // Custom Return Button (fixed top-left with border) const ReturnButton = () => ( - + + {/* Upload Modal */} + setShowModal(false)}> + +
+ +
+
- {images.map((image) => { - return ( - - - - ); - })} + {images ? ( + images.map((image) => { + return ( + + + + ); + }) + ) : ( + <> + )} diff --git a/frontend/src/pages/Collections.tsx b/frontend/src/pages/Collections.tsx index 8774a3c..8e26ff0 100644 --- a/frontend/src/pages/Collections.tsx +++ b/frontend/src/pages/Collections.tsx @@ -1,4 +1,4 @@ -import { api } from "api/api"; +import { Api } from "api/api"; import axios, { AxiosInstance } from "axios"; import CardItem from "components/card"; import NewCardItem from "components/new_card"; @@ -32,7 +32,7 @@ const Collections = () => { Authorization: `Bearer ${auth?.token}`, // Add any default headers you need }, }); - const API = new api(apiClient); + const API = new Api(apiClient); return (
diff --git a/frontend/src/pages/Test.tsx b/frontend/src/pages/Test.tsx index d9aa7eb..5a970af 100644 --- a/frontend/src/pages/Test.tsx +++ b/frontend/src/pages/Test.tsx @@ -1,4 +1,4 @@ -import { api } from "api/api"; +import { Api } from "api/api"; import axios, { AxiosInstance } from "axios"; import { ChangeEvent, useState } from "react"; import { Col, Container, Row } from "react-bootstrap"; @@ -37,7 +37,7 @@ const Home = () => { Authorization: "Bearer your_token_here", // Add any default headers you need }, }); - const API = new api(apiClient); + const API = new Api(apiClient); const [message, setMessage] = useState("Linguaphoto"); const [imageURL, setImageURL] = useState(""); const [file, setFile] = useState(null); @@ -65,7 +65,7 @@ const Home = () => { try { const response = await API.handleUpload(formData); - setImageURL(response.s3_url); + if (response) setImageURL(response.image_url); } catch (error) { console.error("Error uploading the file", error); } diff --git a/linguaphoto/api/image.py b/linguaphoto/api/image.py index bcc3947..3d92536 100644 --- a/linguaphoto/api/image.py +++ b/linguaphoto/api/image.py @@ -1,7 +1,9 @@ """Image APIs.""" +from typing import Annotated, List + from crud.image import ImageCrud -from fastapi import APIRouter, Depends, File, UploadFile +from fastapi import APIRouter, Depends, File, Form, UploadFile from models import Image from utils.auth import get_current_user_id @@ -10,9 +12,21 @@ @router.post("/upload", response_model=Image) async def upload_image( - file: UploadFile = File(...), user_id: str = Depends(get_current_user_id), image_crud: ImageCrud = Depends() -) -> dict: + file: UploadFile = File(...), + id: Annotated[str, Form()] = "", + user_id: str = Depends(get_current_user_id), + image_crud: ImageCrud = Depends(), +) -> Image: """Upload Image and create new Image.""" async with image_crud: - image = await image_crud.create_image(file, user_id) - return image.model_dump() + image = await image_crud.create_image(file, user_id, id) + return image + + +@router.get("/get_images", response_model=List[Image]) +async def get_images( + collection_id: str, user_id: str = Depends(get_current_user_id), image_crud: ImageCrud = Depends() +) -> List[Image]: + async with image_crud: + images = await image_crud.get_images(collection_id=collection_id, user_id=user_id) + return images diff --git a/linguaphoto/crud/image.py b/linguaphoto/crud/image.py index ba95e55..9f77ce2 100644 --- a/linguaphoto/crud/image.py +++ b/linguaphoto/crud/image.py @@ -2,10 +2,13 @@ import os import uuid +from typing import List +from boto3.dynamodb.conditions import Key from crud.base import BaseCrud +from errors import ItemNotFoundError from fastapi import HTTPException, UploadFile -from models import Image +from models import Collection, Image from settings import settings from utils.cloudfront_url_signer import CloudFrontUrlSigner @@ -14,7 +17,7 @@ class ImageCrud(BaseCrud): - async def create_image(self, file: UploadFile, user_id: str) -> Image: + async def create_image(self, file: UploadFile, user_id: str, collection_id: str) -> Image: if file.filename is None or not file.filename: raise HTTPException(status_code=400, detail="File name is missing.") # Generate a unique file name @@ -31,6 +34,15 @@ async def create_image(self, file: UploadFile, user_id: str) -> Image: # Upload the file to S3 await self._upload_to_s3(file.file, unique_filename) # Create new Image - new_image = Image.create(image_url=s3_url, user_id=user_id) + new_image = Image.create(image_url=s3_url, user_id=user_id, collection_id=collection_id) await self._add_item(new_image) - return new_image + collection = await self._get_item(collection_id, Collection, True) + if collection: + collection.images.append(new_image.id) + await self._update_item(collection.id, Collection, {"images": collection.images}) + return new_image + raise ItemNotFoundError + + 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 diff --git a/linguaphoto/models.py b/linguaphoto/models.py index 4d6096d..ed78312 100644 --- a/linguaphoto/models.py +++ b/linguaphoto/models.py @@ -71,6 +71,6 @@ class Image(LinguaBaseModel): user: str @classmethod - def create(cls, image_url: str, user_id: str) -> Self: + def create(cls, image_url: str, user_id: str, collection_id: str) -> Self: """Initializes a new User instance with a unique ID, username, email,and hashed password.""" - return cls(id=str(uuid4()), image_url=image_url, user=user_id) + return cls(id=str(uuid4()), image_url=image_url, user=user_id, collection=collection_id)