From fc0b2967efe06f51fdb3bb5657314c41cb019709 Mon Sep 17 00:00:00 2001 From: Serhii Ofii <132130496+SnowGlowedMountain@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:12:19 +0300 Subject: [PATCH 1/2] Serhii milestone 2 fix (#47) * chores_lint_errors_and_path_issues * feature_db_py * fix_audio_issue * chores_lint_error * fix_openAPI_switching * chore_module_insert * fix_edit_collection_fix * fix_frontend_critical_update * fix_feedback2_translation_async * chore:lint * chore:loading_issue --- frontend/package-lock.json | 80 +++++++++ frontend/package.json | 1 + frontend/src/App.css | 6 +- frontend/src/App.tsx | 129 ++++++------- frontend/src/api/api.ts | 15 +- frontend/src/api/auth.ts | 2 +- frontend/src/components/Audio.tsx | 4 +- frontend/src/components/Book.tsx | 10 +- frontend/src/components/LoadingMask.tsx | 12 +- frontend/src/components/UploadContent.tsx | 43 ++++- frontend/src/components/collection/Edit.tsx | 80 +++++---- frontend/src/components/collection/View.tsx | 189 ++++++++++++-------- frontend/src/components/image.tsx | 21 +-- frontend/src/components/nav/Navbar.tsx | 1 - frontend/src/components/nav/Sidebar.tsx | 6 +- frontend/src/contexts/SocketContext.tsx | 59 ++++++ frontend/src/gen/api.ts | 66 ++++++- frontend/src/hooks/theme.tsx | 23 ++- frontend/src/pages/Collection.tsx | 5 +- frontend/src/pages/SubscriptioinType.tsx | 4 +- frontend/src/pages/Subscription.tsx | 14 +- frontend/src/types/model.ts | 2 +- linguaphoto/ai/transcribe.py | 3 +- linguaphoto/api/collection.py | 17 +- linguaphoto/api/image.py | 31 +++- linguaphoto/api/user.py | 9 +- linguaphoto/crud/image.py | 76 ++++---- linguaphoto/main.py | 3 + linguaphoto/requirements.txt | 3 +- linguaphoto/schemas/collection.py | 5 + linguaphoto/schemas/image.py | 7 + linguaphoto/schemas/user.py | 1 + linguaphoto/settings.py | 2 +- linguaphoto/socket.py | 47 +++++ 34 files changed, 688 insertions(+), 288 deletions(-) create mode 100644 frontend/src/contexts/SocketContext.tsx create mode 100644 linguaphoto/schemas/image.py create mode 100644 linguaphoto/socket.py diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 79ac845..0dbd0d0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "react-icons": "^5.3.0", "react-images-uploading": "^3.1.7", "react-scripts": "5.0.1", + "socket.io-client": "^4.8.0", "typescript": "^4.3.0", "web-vitals": "^2.1.4" }, @@ -3736,6 +3737,11 @@ "type-detect": "4.0.8" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@stripe/react-stripe-js": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.8.0.tgz", @@ -7585,6 +7591,46 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz", + "integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", @@ -18691,6 +18737,32 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.0.tgz", + "integrity": "sha512-C0jdhD5yQahMws9alf/yvtsMGTaIDBnZ8Rb5HU56svyq0l5LIrGzIDZZD5pHQlmzxLuU91Gz+VpQMKgCTNYtkw==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -21343,6 +21415,14 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.1.tgz", + "integrity": "sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5cb1a8d..c83b040 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "react-icons": "^5.3.0", "react-images-uploading": "^3.1.7", "react-scripts": "5.0.1", + "socket.io-client": "^4.8.0", "typescript": "^4.3.0", "web-vitals": "^2.1.4" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index 43ca4da..ba3baa8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -3,6 +3,10 @@ @tailwind components; @tailwind utilities; +html { + scroll-behavior: smooth; /* Smooth scrolling for anchor links */ +} + body { font-family: "Noto Serif SC", serif; /* Use Noto Serif Simplified Chinese font */ @@ -81,7 +85,7 @@ body { /* Hover and focus states for buttons */ button:hover { - @apply bg-blue-600; + @apply opacity-80; } button:focus { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b9cbbfe..6b7c0d1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import Navbar from "components/nav/Navbar"; import NotFoundRedirect from "components/NotFoundRedirect"; import { AuthProvider } from "contexts/AuthContext"; import { LoadingProvider } from "contexts/LoadingContext"; +import { SocketProvider } from "contexts/SocketContext"; import { AlertQueue, AlertQueueProvider } from "hooks/alerts"; import { ThemeProvider } from "hooks/theme"; import CollectionPage from "pages/Collection"; @@ -23,69 +24,71 @@ const App = () => { - - - - - } /> - } /> - } - requiredSubscription={true} // Set true if subscription is required for this route - /> - } - /> - } - requiredSubscription={true} // Set true if subscription is required for this route - /> - } - /> - } - requiredSubscription={true} // Set true if subscription is required for this route - /> - } - /> - } - requiredSubscription={true} // Set true if subscription is required for this route - /> - } - /> - } - requiredSubscription={true} // Set true if subscription is required for this route - /> - } - /> - } /> - } - /> - } /> - - - - {/* */} - - + + + + + + } /> + } /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> + } /> + } + /> + } /> + + + + {/* */} + + + diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 079c0b5..aa5d3a3 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,5 +1,5 @@ import { AxiosInstance } from "axios"; -import { Collection, Image } from "types/model"; +import { Collection, ImageType } from "types/model"; export interface CollectionCreateFragment { title: string; @@ -22,7 +22,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: { @@ -61,20 +61,25 @@ export class Api { await this.api.get(`/delete_image?id=${image_id}`); return null; } - public async uploadImage(file: File, collection_id: string): Promise { + 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> { + public async getImages(collection_id: string): Promise> { const response = await this.api.get( `/get_images?collection_id=${collection_id}`, ); return response.data; } - public async translateImages(images: Array): Promise> { + public async translateImages( + images: Array, + ): Promise> { const response = await this.api.post("/translate", images, { timeout: 300000, }); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 0afed43..ce502e5 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -2,7 +2,7 @@ import axios from "axios"; import { Response, SigninData, SignupData } from "types/auth"; -const API_URL = process.env.REACT_APP_BACKEND_URL || "https://localhost:8080"; +const API_URL = process.env.REACT_APP_BACKEND_URL || "http://localhost:8080"; export const signup = async (data: SignupData): Promise => { const response = await axios.post(`${API_URL}/signup`, data); diff --git a/frontend/src/components/Audio.tsx b/frontend/src/components/Audio.tsx index 9d92b1f..06429c1 100644 --- a/frontend/src/components/Audio.tsx +++ b/frontend/src/components/Audio.tsx @@ -7,10 +7,10 @@ import { FaVolumeDown, FaVolumeUp, } from "react-icons/fa"; -import { Image } from "types/model"; +import { ImageType } from "types/model"; interface AudioPlayerProps { - currentImage: Image; // current image + currentImage: ImageType; // current image index: number; // current transcription index handleTranscriptionNext: () => void; //next transcript handleTranscriptionPrev: () => void; //prev transcript diff --git a/frontend/src/components/Book.tsx b/frontend/src/components/Book.tsx index 0c6cf8d..c4d6bba 100644 --- a/frontend/src/components/Book.tsx +++ b/frontend/src/components/Book.tsx @@ -39,9 +39,9 @@ const Book: React.FC = ({ {/* Second page */}
-
-

{title}

-

{description}

+
+

{title}

+

{description}

{is_editable ? (
@@ -74,8 +74,8 @@ const Book: React.FC = ({ alt={title} className="object-cover w-full h-full rounded-sm" /> -
-

{title}

+
+

{title}

diff --git a/frontend/src/components/LoadingMask.tsx b/frontend/src/components/LoadingMask.tsx index 41f2830..240b7c1 100644 --- a/frontend/src/components/LoadingMask.tsx +++ b/frontend/src/components/LoadingMask.tsx @@ -1,6 +1,7 @@ // src/components/LoadingMask.tsx import { useLoading } from "contexts/LoadingContext"; import React from "react"; +import { FaFan } from "react-icons/fa"; // Import fan icon const LoadingMask: React.FC = () => { const { loading } = useLoading(); @@ -8,9 +9,14 @@ const LoadingMask: React.FC = () => { if (!loading) return null; return ( -
-
- Loading... +
+
+ {/* Spinning Fan */} +
+ +
+ 请稍候...{" "} + {/* "Please wait..." in Chinese */}
); diff --git a/frontend/src/components/UploadContent.tsx b/frontend/src/components/UploadContent.tsx index d0b1cfb..f3a99dc 100644 --- a/frontend/src/components/UploadContent.tsx +++ b/frontend/src/components/UploadContent.tsx @@ -1,7 +1,9 @@ +// src/components/UploadContent.tsx import { useAlertQueue } from "hooks/alerts"; -import { FC, useState } from "react"; +import { FC, useEffect, 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 } @@ -21,12 +23,11 @@ const UploadContent: FC = ({ onUpload }) => { const file = images[i].file as File; try { await onUpload(file); // Upload each file one by one - if (i == images.length - 1) - addAlert(`${i + 1} images has been uploaded!`, "success"); + if (i === images.length - 1) + addAlert(`${i + 1} images have been uploaded!`, "success"); } catch (error) { - addAlert(`${file.name} has been failed to upload. ${error}`, "error"); + addAlert(`${file.name} failed to upload. ${error}`, "error"); break; - // Optionally, handle failure feedback here } } setImages([]); // Clear images after uploading @@ -37,6 +38,35 @@ const UploadContent: FC = ({ onUpload }) => { setImages(imageList); }; + // Handle image pasting from the clipboard + const handlePaste = (event: ClipboardEvent) => { + const clipboardItems = event.clipboardData?.items; + if (!clipboardItems) return; + + for (let i = 0; i < clipboardItems.length; i++) { + const item = clipboardItems[i]; + if (item.type.startsWith("image")) { + const file = item.getAsFile(); + if (file) { + setImages((prevImages) => [ + ...prevImages, + { file, data_url: URL.createObjectURL(file) }, + ]); + addAlert("Image pasted from clipboard!", "success"); + } + } + } + }; + + useEffect(() => { + const pasteListener = (event: Event) => + handlePaste(event as ClipboardEvent); + window.addEventListener("paste", pasteListener); + return () => { + window.removeEventListener("paste", pasteListener); + }; + }, []); + return (
= ({ onUpload }) => { {...dragProps} // Drag-n-Drop props >

- Drag & drop images here, or click to select files + Drag & drop images here, click to select files, or paste an + image from your clipboard

diff --git a/frontend/src/components/collection/Edit.tsx b/frontend/src/components/collection/Edit.tsx index c9448e7..b908c2c 100644 --- a/frontend/src/components/collection/Edit.tsx +++ b/frontend/src/components/collection/Edit.tsx @@ -6,14 +6,16 @@ import Modal from "components/modal"; import UploadContent from "components/UploadContent"; import { useAuth } from "contexts/AuthContext"; import { useLoading } from "contexts/LoadingContext"; +import { useSocket } from "contexts/SocketContext"; import { useAlertQueue } from "hooks/alerts"; import { useEffect, useMemo, useState } from "react"; import { ListManager } from "react-beautiful-dnd-grid"; -import { Collection, Image } from "types/model"; +import { Collection, ImageType } from "types/model"; type CollectionEditProps = { collection: Collection; setCollection: React.Dispatch>; }; +const skeletons = Array(5).fill(null); const CollectionEdit: React.FC = ({ collection, setCollection, @@ -22,10 +24,12 @@ const CollectionEdit: React.FC = ({ const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const { auth, client } = useAuth(); + const { updated_image } = useSocket(); const { startLoading, stopLoading } = useLoading(); const [showUploadModal, setShowUploadModal] = useState(false); const [showDeleteImageModal, setShowDeleteImageModal] = useState(false); - const [images, setImages] = useState | undefined>([]); + const [images, setImages] = useState | undefined>([]); + const [is_loading, setIsLoading] = useState(true); const [reorderImageIds, setReorderImageIds] = useState | null>( [], ); @@ -43,23 +47,18 @@ const CollectionEdit: React.FC = ({ }); if (error) addAlert(error.detail?.toString(), "error"); else setImages(images); + setIsLoading(false); }; asyncfunction(); } }, [collection.id]); - useEffect(() => {}, [collection.images]); - 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], - ); + useEffect(() => { + if (updated_image && images) { + const img = images?.find((image) => image.id == updated_image.id); + if (img) img.is_translated = true; + setImages([...images]); + } + }, [updated_image]); const apiClient1: AxiosInstance = useMemo( () => axios.create({ @@ -72,7 +71,6 @@ const CollectionEdit: React.FC = ({ }), [auth?.token], ); - const API = useMemo(() => new Api(apiClient), [apiClient]); const API_Uploader = useMemo(() => new Api(apiClient1), [apiClient1]); const handleSave = async (e: React.FormEvent) => { e.preventDefault(); @@ -138,10 +136,15 @@ const CollectionEdit: React.FC = ({ const new_image = await API_Uploader.uploadImage(file, collection?.id); stopLoading(); if (new_image) { + const first_image = new_image.image_url; if (collection.images.length == 0 || images == undefined) { // no images setImages([new_image]); - setFeaturedImage(new_image.image_url); + client.POST("/set_featured_image", { + body: { image_url: first_image, collection_id: collection.id }, + }); + // set the first image as featured one + setFeaturedImage(first_image); } else { // images exist images.push(new_image); @@ -160,11 +163,7 @@ const CollectionEdit: React.FC = ({ "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"); + await client.POST("/translate", { body: { image_id } }); stopLoading(); } }; @@ -200,7 +199,9 @@ const CollectionEdit: React.FC = ({ setReorderImageIds([...reorderImageIds]); //featured image const image = images?.find((img) => img.id == reorderImageIds[0]); - if (image) setFeaturedImage(image?.image_url); + if (image) { + setFeaturedImage(image?.image_url); + } // Optionally, you can save the new order to your backend here }; return ( @@ -275,36 +276,37 @@ const CollectionEdit: React.FC = ({ Order Save
*/} - {/* reordering part */} + {/* skeleton part */}
+ {is_loading && + skeletons.map((__, index) => ( +
+
+
+
+
+
+ ))} + {/* reordering part */} {reorderImageIds && images && ( { const image = images.find((item) => item.id === id); return ( -
- {image ? ( +
+ {image && ( - ) : ( - )}
); diff --git a/frontend/src/components/collection/View.tsx b/frontend/src/components/collection/View.tsx index 603d3c3..9464037 100644 --- a/frontend/src/components/collection/View.tsx +++ b/frontend/src/components/collection/View.tsx @@ -3,43 +3,48 @@ import Container from "components/HOC/Container"; import { useAuth } from "contexts/AuthContext"; import { useAlertQueue } from "hooks/alerts"; import { useEffect, useMemo, useState } from "react"; -import { Collection, Image } from "types/model"; +import { Collection, ImageType } from "types/model"; + type CollectionViewProps = { collection: Collection; }; + const CollectionView: React.FC = ({ collection }) => { const [currentImageIndex, setCurrentImageIndex] = useState(0); const [currentTranscriptionIndex, setCurrentTranscriptionIndex] = useState(0); - const [currentImage, setCurrentImage] = useState(null); + const [currentImage, setCurrentImage] = useState(null); const [isLoading, setIsLoading] = useState(true); const { client } = useAuth(); const { addAlert } = useAlertQueue(); - const [images, setImages] = useState | undefined>([]); + const [images, setImages] = useState | undefined>([]); + // Get translated images const translatedImages = useMemo(() => { if (images) { - // Get translated images const filter = images.filter((img) => img.is_translated); const final_filter = collection.images ?.map((img) => { const foundItem = filter.find((item) => item.id == img); - return foundItem ? foundItem : null; // Return `null` or skip + return foundItem ? foundItem : null; }) - .filter(Boolean); // Filters out `null` or `undefined` + .filter(Boolean); if (final_filter) return final_filter; } return []; }, [images]); - useEffect(() => { - if (translatedImages.length > 0) { - setCurrentImage(translatedImages[currentImageIndex]); - } - }, [currentImageIndex, translatedImages]); + // Preload images one by one + const preloadImage = (src: string) => { + return new Promise((resolve) => { + const img = new Image(); + img.src = src; + img.onload = () => resolve(); + }); + }; useEffect(() => { if (collection) { - const asyncfunction = async () => { + const fetchImages = async () => { const { data: images, error } = await client.GET("/get_images", { params: { query: { collection_id: collection.id } }, }); @@ -47,16 +52,29 @@ const CollectionView: React.FC = ({ collection }) => { else setImages(images); setIsLoading(false); }; - asyncfunction(); + fetchImages(); } }, [collection?.id]); + useEffect(() => { + const loadImagesSequentially = async () => { + if (translatedImages.length > 0) { + for (const image of translatedImages) { + if (image) await preloadImage(image.image_url); + } + setCurrentImage(translatedImages[currentImageIndex]); + } + }; + + loadImagesSequentially(); + }, [translatedImages, currentImageIndex]); + // Navigate between images const handleNext = () => { if (currentImageIndex < translatedImages.length - 1) { setCurrentImageIndex(currentImageIndex + 1); setCurrentTranscriptionIndex(0); - window.scrollTo(0, 0); // This instantly jumps the viewport to the top + window.scrollTo(0, 0); } }; @@ -64,7 +82,7 @@ const CollectionView: React.FC = ({ collection }) => { if (currentImageIndex > 0) { setCurrentImageIndex(currentImageIndex - 1); setCurrentTranscriptionIndex(0); - window.scrollTo(0, 0); // This instantly jumps the viewport to the top + window.scrollTo(0, 0); } }; @@ -83,19 +101,36 @@ const CollectionView: React.FC = ({ collection }) => { setCurrentTranscriptionIndex(currentTranscriptionIndex - 1); } }; + const handlePhotoClick = (e: React.MouseEvent) => { - // Calculate the click position relative to the component const { clientX, currentTarget } = e; const { left, right } = currentTarget.getBoundingClientRect(); const width = right - left; - - // Determine if the click was on the left or right side if (clientX < left + width / 2) { - handlePrev(); // Clicked on the left side + handlePrev(); } else { - handleNext(); // Clicked on the right side + handleNext(); } }; + + const handleKey = (event: KeyboardEvent) => { + switch (event.key) { + case "ArrowLeft": + handlePrev(); + break; + case "ArrowRight": + handleNext(); + break; + } + }; + + useEffect(() => { + window.addEventListener("keydown", handleKey); + return () => { + window.removeEventListener("keydown", handleKey); + }; + }, [handleKey]); + return (
{isLoading ? ( @@ -103,60 +138,69 @@ const CollectionView: React.FC = ({ collection }) => {
- ) : currentImage ? ( -
-
- Collection Image -
-
- - {/* transcription */} -
-

- {currentImage.transcriptions.map((transcription, index) => { - return ( - +

+ Collection Image +
+
+ +
+ {currentImage.transcriptions.length != 0 ? ( + <> +

+ {currentImage.transcriptions.map( + (transcription, index) => { + return ( + + {transcription.text} + + ); + }, + )} +

+

+ { + currentImage.transcriptions[currentTranscriptionIndex] + .pinyin + } +

+

+ { + currentImage.transcriptions[currentTranscriptionIndex] + .translation } - > - {transcription.text} - - ); - })} -

-

- { - currentImage.transcriptions[currentTranscriptionIndex] - .pinyin - } -

-

- { - currentImage.transcriptions[currentTranscriptionIndex] - .translation - } -

- -
-
+

+ + + ) : ( +
No transcript
+ )} +
+ +
-
+ ) ) : (

@@ -167,4 +211,5 @@ const CollectionView: React.FC = ({ collection }) => {

); }; + export default CollectionView; diff --git a/frontend/src/components/image.tsx b/frontend/src/components/image.tsx index 7f1f2b4..f662f34 100644 --- a/frontend/src/components/image.tsx +++ b/frontend/src/components/image.tsx @@ -1,13 +1,8 @@ import React from "react"; -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 { +import { CheckCircleFill, LockFill, TrashFill } from "react-bootstrap-icons"; +import { ImageType } from "types/model"; +// Extend the existing ImageType interface to include the new function +interface ImageWithFunction extends ImageType { handleTranslateOneImage: (image_id: string) => void; showDeleteModal: (id: string) => void; } @@ -20,10 +15,10 @@ const ImageComponent: React.FC = ({ }) => { return (
-
+
{is_translated ? ( <>
@@ -43,10 +38,10 @@ const ImageComponent: React.FC = ({
{is_translated ? (
- + */} +
); }; @@ -108,7 +108,7 @@ const Sidebar = ({ show, onClose }: SidebarProps) => { /> {auth?.is_auth ? ( } onClick={() => { navigate("/collections"); diff --git a/frontend/src/contexts/SocketContext.tsx b/frontend/src/contexts/SocketContext.tsx new file mode 100644 index 0000000..6fde6e5 --- /dev/null +++ b/frontend/src/contexts/SocketContext.tsx @@ -0,0 +1,59 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; +import io, { Socket } from "socket.io-client"; +import { ImageType } from "types/model"; +import { useAuth } from "./AuthContext"; + +interface SocketContextProps { + socket: Socket | null; + updated_image: ImageType | null; +} + +const SERVER_URL = process.env.REACT_APP_BACKEND_URL || "http://localhost:8080"; + +// Create the context +const SocketContext = createContext({ + socket: null, + updated_image: null, +}); + +// Provider component +export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [socket, setSocket] = useState(null); + const { auth } = useAuth(); + const [updated_image, setImage] = useState(null); + useEffect(() => { + if (auth?.id) { + const newSocket = io(SERVER_URL); + newSocket.on("connect", () => { + console.log("Connected to server"); + newSocket.emit("register_user", auth.id); // Register the user with their ID + }); + + newSocket.on("disconnect", () => { + console.log("Disconnected from server"); + }); + // Listen for the 'notification' event from the server + newSocket.on("notification", (data: ImageType) => { + console.log(data); + setImage({ ...data }); + }); + + setSocket(newSocket); + + return () => { + newSocket.disconnect(); // Clean up on unmount + }; + } + }, [auth?.id]); + + return ( + + {children} + + ); +}; + +// Custom hook to use the socket context +export const useSocket = () => useContext(SocketContext); diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 750a8c7..7809132 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -2,7 +2,7 @@ * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ -import { Collection, Image } from "types/model"; +import { Collection, ImageType } from "types/model"; export interface paths { "/translate": { parameters: { @@ -11,7 +11,7 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: operations["delete_collections"]; put?: never; post: operations["translate"]; /** Delete Artifact */ @@ -21,6 +21,23 @@ export interface paths { patch?: never; trace?: never; }; + "/set_featured_image": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["public_collections"]; + put?: never; + post: operations["set_featured_image"]; + /** Delete Artifact */ + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/get_images": { parameters: { query?: never; @@ -667,6 +684,7 @@ export interface components { }; /** UserInfoResponseItem */ UserInfoResponseItem: { + id: string; token: string; username: string; email: string; @@ -1205,7 +1223,7 @@ export interface operations { }; requestBody: { content: { - "application/json": Array; + "application/json": { image_id: string }; }; }; responses: { @@ -1215,7 +1233,43 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": Array; + "application/json": null; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_featured_image: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + image_url: string; + collection_id: string; + }; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": null; }; }; /** @description Validation Error */ @@ -1244,7 +1298,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": Array; + "application/json": Array; }; }; /** @description Validation Error */ @@ -1273,7 +1327,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": never; + "application/json": unknown; }; }; /** @description Validation Error */ diff --git a/frontend/src/hooks/theme.tsx b/frontend/src/hooks/theme.tsx index ad19870..34123f8 100644 --- a/frontend/src/hooks/theme.tsx +++ b/frontend/src/hooks/theme.tsx @@ -27,7 +27,8 @@ const COLORS: { [key in Theme]: ThemeColors } = { }; const getThemeFromLocalStorage = (): Theme => { - const theme = localStorage.getItem(THEME_KEY); + // const theme = localStorage.getItem(THEME_KEY); //now light mode disable + const theme = "dark"; if (theme === null) { return "dark"; } @@ -60,6 +61,26 @@ export const ThemeProvider = (props: ThemeProviderProps) => { setThemeWithLocalStorage(theme); }; + const scrollSpeed = 10; // Increase this value to make scrolling faster + + useEffect(() => { + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); // Prevent default scroll behavior + window.scrollBy({ + top: event.deltaY * scrollSpeed, // Multiply the scroll amount + left: 0, + behavior: "smooth", // Use smooth scrolling + }); + }; + + window.addEventListener("wheel", handleWheel, { passive: false }); + + // Clean up the event listener on component unmount + return () => { + window.removeEventListener("wheel", handleWheel); + }; + }, []); // Empty dependency array ensures this runs only on mount and unmount + useEffect(() => { document.body.setAttribute("data-bs-theme", theme); document.body.classList.toggle("dark", theme === "dark"); diff --git a/frontend/src/pages/Collection.tsx b/frontend/src/pages/Collection.tsx index 6b92adb..00571c5 100644 --- a/frontend/src/pages/Collection.tsx +++ b/frontend/src/pages/Collection.tsx @@ -2,6 +2,7 @@ import CollectionEdit from "components/collection/Edit"; import CollectionNew from "components/collection/New"; import CollectionView from "components/collection/View"; import { useAuth } from "contexts/AuthContext"; +import { useLoading } from "contexts/LoadingContext"; import { useAlertQueue } from "hooks/alerts"; import React, { useEffect, useMemo, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; @@ -15,7 +16,7 @@ const CollectionPage: React.FC = () => { ); const { auth, client } = useAuth(); const { addAlert } = useAlertQueue(); - + const { startLoading, stopLoading } = useLoading(); // Helper to check if it's an edit action const isEditAction = useMemo( () => location.search.includes("Action=edit"), @@ -26,12 +27,14 @@ const CollectionPage: React.FC = () => { useEffect(() => { if (id && auth?.is_auth) { const asyncfunction = async () => { + startLoading(); const { data: collection, error } = await client.GET( "/get_collection", { params: { query: { id } } }, ); if (error) addAlert(error.detail?.toString(), "error"); else setCollection(collection); + stopLoading(); }; asyncfunction(); } diff --git a/frontend/src/pages/SubscriptioinType.tsx b/frontend/src/pages/SubscriptioinType.tsx index 561300c..4d36be4 100644 --- a/frontend/src/pages/SubscriptioinType.tsx +++ b/frontend/src/pages/SubscriptioinType.tsx @@ -81,7 +81,7 @@ const CheckoutForm = () => { return (

Subscribe

@@ -149,7 +149,7 @@ const CheckoutForm = () => { const SubscriptionPage = () => { return ( -
+
diff --git a/frontend/src/pages/Subscription.tsx b/frontend/src/pages/Subscription.tsx index b7c0eb3..341d09b 100644 --- a/frontend/src/pages/Subscription.tsx +++ b/frontend/src/pages/Subscription.tsx @@ -2,26 +2,28 @@ import React from "react"; const SubscriptionCancelPage: React.FC = () => { return ( -
-

+
+

This page is on developing. will be updated soon

-
+
{/* Current Plan Section */}

Current Plan

-

+

You have been subscribed to the{" "} Premium Plan.

-
{/* Billing Cycle Section */}
-

Current Billing Cycle

+

+ Current Billing Cycle +

2023.05.05 - 2023.06.05

diff --git a/frontend/src/types/model.ts b/frontend/src/types/model.ts index 4770409..fc73865 100644 --- a/frontend/src/types/model.ts +++ b/frontend/src/types/model.ts @@ -14,7 +14,7 @@ interface Transcription { translation: string; audio_url: string; } -export interface Image { +export interface ImageType { id: string; is_translated: boolean; collection: string; diff --git a/linguaphoto/ai/transcribe.py b/linguaphoto/ai/transcribe.py index 5048385..174921c 100644 --- a/linguaphoto/ai/transcribe.py +++ b/linguaphoto/ai/transcribe.py @@ -38,6 +38,8 @@ example, when reading manga, read from right to left, top to bottom. When reading a book, if the text is in columns, read from right to left, top to bottom. If the text is in rows, read from left to right, top to bottom. +note: the response must be only JSON content without any comment, symbol, or anything. +In case you don't find any Chinese characters, return {"transcriptions": []} """.strip() @@ -89,7 +91,6 @@ async def transcribe_image(image_source: BytesIO, client: AsyncOpenAI) -> Transc raw_response = data["choices"][0]["message"]["content"] transcription_response = TranscriptionResponse.model_validate_json(raw_response) - return transcription_response diff --git a/linguaphoto/api/collection.py b/linguaphoto/api/collection.py index a8c0ce8..0af5640 100644 --- a/linguaphoto/api/collection.py +++ b/linguaphoto/api/collection.py @@ -5,12 +5,12 @@ from fastapi import APIRouter, Depends from linguaphoto.crud.collection import CollectionCrud -from linguaphoto.errors import NotAuthorizedError from linguaphoto.models import Collection from linguaphoto.schemas.collection import ( CollectionCreateFragment, CollectionEditFragment, CollectionPublishFragment, + FeaturedImageFragnment, ) from linguaphoto.utils.auth import get_current_user_id @@ -42,8 +42,8 @@ async def getcollection( collection = await collection_crud.get_collection(id) if collection is None: raise ValueError - if collection.user != user_id: - raise NotAuthorizedError + # if collection.user != user_id: + # raise NotAuthorizedError return collection @@ -86,6 +86,17 @@ async def deletecollection( return +@router.post("/set_featured_image", response_model=None) +async def setfeaturedimage( + data: FeaturedImageFragnment, + user_id: str = Depends(get_current_user_id), + collection_crud: CollectionCrud = Depends(), +) -> None: + async with collection_crud: + await collection_crud.edit_collection(data.collection_id, updates={"featured_image": data.image_url}) + return + + @router.post("/publish_collection") async def publishcollection( data: CollectionPublishFragment, diff --git a/linguaphoto/api/image.py b/linguaphoto/api/image.py index e188bdb..0f318ff 100644 --- a/linguaphoto/api/image.py +++ b/linguaphoto/api/image.py @@ -1,5 +1,6 @@ """Image APIs.""" +import asyncio from typing import Annotated, List from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile @@ -7,9 +8,21 @@ from linguaphoto.crud.collection import CollectionCrud from linguaphoto.crud.image import ImageCrud from linguaphoto.models import Image +from linguaphoto.schemas.image import ImageTranslateFragment +from linguaphoto.socket import notify_user from linguaphoto.utils.auth import get_current_user_id, subscription_validate router = APIRouter() +translating_images: List[str] = [] + + +async def translate_background(image_id: str, image_crud: ImageCrud, user_id: str) -> None: + async with image_crud: + translating_images.append(image_id) + image = await image_crud.translate(image_id, user_id) + translating_images.remove(image_id) + if image: + await notify_user(user_id, image.model_dump()) # Use await here @router.post("/upload", response_model=Image) @@ -23,6 +36,9 @@ async def upload_image( """Upload Image and create new Image.""" async with image_crud: image = await image_crud.create_image(file, user_id, id) + if image: + # Run translate in the background + asyncio.create_task(translate_background(image.id, image_crud, user_id)) return image @@ -56,13 +72,16 @@ async def delete_image( raise HTTPException(status_code=400, detail="Image is invalid") -@router.post("/translate", response_model=List[Image]) +@router.post("/translate", response_model=None) async def translate( - data: List[str], + data: ImageTranslateFragment, user_id: str = Depends(get_current_user_id), image_crud: ImageCrud = Depends(), is_subscribed: bool = Depends(subscription_validate), -) -> List[Image]: - async with image_crud: - images = await image_crud.translate(data, user_id=user_id) - return images +) -> None: + image_id = data.image_id + if image_id not in translating_images: + async with image_crud: + translating_images.append(image_id) + asyncio.create_task(translate_background(image_id, image_crud, user_id=user_id)) + translating_images.remove(image_id) diff --git a/linguaphoto/api/user.py b/linguaphoto/api/user.py index 02a03ae..edb7579 100644 --- a/linguaphoto/api/user.py +++ b/linguaphoto/api/user.py @@ -29,14 +29,7 @@ async def signup(user: UserSignupFragment, user_crud: UserCrud = Depends()) -> d async with user_crud: new_user = await user_crud.create_user_from_email(user) if new_user is None: - print( - UserSigninRespondFragment( - token="", username=user.username, email=user.email, is_subscription=False, is_auth=False - ).model_dump() - ) - return UserSigninRespondFragment( - token="", username=user.username, email=user.email, is_subscription=False, is_auth=False - ).model_dump() + return None token = create_access_token({"id": new_user.id}, timedelta(hours=24)) res_user = new_user.model_dump() res_user.update({"token": token, "is_auth": True}) diff --git a/linguaphoto/crud/image.py b/linguaphoto/crud/image.py index f126908..9c8a8bf 100644 --- a/linguaphoto/crud/image.py +++ b/linguaphoto/crud/image.py @@ -71,6 +71,12 @@ async def create_audio(self, audio_source: BytesIO) -> str: 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)) + images = await self._list_items( + item_class=Image, + filter_expression="#collection=:collection", + expression_attribute_names={"#collection": "collection"}, + expression_attribute_values={":collection": collection_id}, + ) return images async def get_image(self, image_id: str) -> Image | None: @@ -81,40 +87,36 @@ 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 = [] - for id in images: - # Retrieve image metadata and download the image content - image_instance = await self._get_item(id, Image, True) - if image_instance is None: - continue - response = requests.get(image_instance.image_url) - if response.status_code == 200: - img_source = BytesIO(response.content) - # Initialize OpenAI client for transcription and speech synthesis - client = AsyncOpenAI(api_key=settings.openai_key) - transcription_response = await transcribe_image(img_source, client) - # Process each transcription and generate corresponding audio - for i, transcription in enumerate(transcription_response.transcriptions): - audio_buffer = BytesIO() - text = transcription.text - # Synthesize text and write the chunks directly into the in-memory buffer - async for chunk in await synthesize_text(text, client): - audio_buffer.write(chunk) - # Set buffer position to the start - audio_buffer.seek(0) - audio_url = await self.create_audio(audio_buffer) - if audio_url is None: - continue - # Attach the audio URL to the transcription - transcription.audio_url = audio_url - image_instance.transcriptions = transcription_response.transcriptions - image_instance.is_translated = True - await self._update_item( - id, - Image, - {"transcriptions": transcription_response.model_dump()["transcriptions"], "is_translated": True}, - ) - if image_instance: - image_instances.append(image_instance) - return image_instances + async def translate(self, image_id: str, user_id: str) -> Image: + # Retrieve image metadata and download the image content + image_instance = await self._get_item(image_id, Image, True) + if image_instance is None: + raise ItemNotFoundError + response = requests.get(image_instance.image_url) + if response.status_code == 200: + img_source = BytesIO(response.content) + # Initialize OpenAI client for transcription and speech synthesis + client = AsyncOpenAI(api_key=settings.openai_key) + transcription_response = await transcribe_image(img_source, client) + # Process each transcription and generate corresponding audio + for i, transcription in enumerate(transcription_response.transcriptions): + audio_buffer = BytesIO() + text = transcription.text + # Synthesize text and write the chunks directly into the in-memory buffer + async for chunk in await synthesize_text(text, client): + audio_buffer.write(chunk) + # Set buffer position to the start + audio_buffer.seek(0) + audio_url = await self.create_audio(audio_buffer) + if audio_url is None: + continue + # Attach the audio URL to the transcription + transcription.audio_url = audio_url + image_instance.transcriptions = transcription_response.transcriptions + image_instance.is_translated = True + await self._update_item( + image_id, + Image, + {"transcriptions": transcription_response.model_dump()["transcriptions"], "is_translated": True}, + ) + return image_instance diff --git a/linguaphoto/main.py b/linguaphoto/main.py index b92c07a..ce019b7 100644 --- a/linguaphoto/main.py +++ b/linguaphoto/main.py @@ -3,8 +3,10 @@ import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from socketio import ASGIApp from linguaphoto.api.api import router +from linguaphoto.socket import sio # Import the `sio` and `notify_user` from socket.py app = FastAPI() @@ -19,6 +21,7 @@ app.include_router(router, prefix="") +app = ASGIApp(sio, app) if __name__ == "__main__": print("Starting webserver...") uvicorn.run(app, port=8080, host="0.0.0.0") diff --git a/linguaphoto/requirements.txt b/linguaphoto/requirements.txt index d57a88c..3ffa6c2 100644 --- a/linguaphoto/requirements.txt +++ b/linguaphoto/requirements.txt @@ -39,4 +39,5 @@ python-dotenv openai requests -stripe \ No newline at end of file +stripe +python-socketio[asgi] \ No newline at end of file diff --git a/linguaphoto/schemas/collection.py b/linguaphoto/schemas/collection.py index bb65524..c6d283c 100644 --- a/linguaphoto/schemas/collection.py +++ b/linguaphoto/schemas/collection.py @@ -21,3 +21,8 @@ class CollectionEditFragment(BaseModel): class CollectionPublishFragment(BaseModel): id: str flag: bool + + +class FeaturedImageFragnment(BaseModel): + image_url: str + collection_id: str diff --git a/linguaphoto/schemas/image.py b/linguaphoto/schemas/image.py new file mode 100644 index 0000000..f9e6620 --- /dev/null +++ b/linguaphoto/schemas/image.py @@ -0,0 +1,7 @@ +"""Image schemas for validating and API integration.""" + +from pydantic import BaseModel + + +class ImageTranslateFragment(BaseModel): + image_id: str diff --git a/linguaphoto/schemas/user.py b/linguaphoto/schemas/user.py index 5f8481d..bd7ccf7 100644 --- a/linguaphoto/schemas/user.py +++ b/linguaphoto/schemas/user.py @@ -23,6 +23,7 @@ class UserSigninFragment(BaseModel): class UserSigninRespondFragment(BaseModel): + id: str token: str username: str email: EmailStr diff --git a/linguaphoto/settings.py b/linguaphoto/settings.py index 6e4fd95..bb0cc12 100644 --- a/linguaphoto/settings.py +++ b/linguaphoto/settings.py @@ -28,7 +28,7 @@ class Settings: openai_key = os.getenv("OPENAI_API_KEY") stripe_key = os.getenv("STRIPE_API_KEY") stripe_price_id = os.getenv("STRIPE_PRODUCT_PRICE_ID", "price_1Q0ZaMKeTo38dsfeSWRDGCEf") - homepage_url = os.getenv("HOMEPAGE_URL", "") + homepage_url = os.getenv("HOMEPAGE_URL", "http://localhost:3000") settings = Settings() diff --git a/linguaphoto/socket.py b/linguaphoto/socket.py new file mode 100644 index 0000000..f178be2 --- /dev/null +++ b/linguaphoto/socket.py @@ -0,0 +1,47 @@ +"""Socket part.""" + +import socketio + +from linguaphoto.settings import settings + +# Create a new Socket.IO server with CORS enabled +sio = socketio.AsyncServer( + async_mode="asgi", + cors_allowed_origins=[settings.homepage_url], # Update this to match your frontend URL +) + +# Dictionary to store connected users by their socket ID +connected_users: dict[str, str] = {} + + +# Handle client connection +@sio.event +async def connect(sid: str) -> None: + print(f"User connected: {sid}") + + +# Handle client disconnection +@sio.event +async def disconnect(sid: str) -> None: + if sid in connected_users: + print(f"User {connected_users[sid]} disconnected") + del connected_users[sid] + + +# Event for registering a specific user (e.g., after authentication) +@sio.event +async def register_user(sid: str, user_id: str) -> None: + connected_users[user_id] = sid + print(f"User {user_id} registered with session ID {sid}") + + +# Event to notify a specific user +async def notify_user(user_id: str, message: dict) -> None: + sid = connected_users.get(user_id) + if sid: + await sio.emit("notification", message, room=sid) + else: + print(f"User {user_id} is not connected") + + +# Export the `sio` instance so it can be used in other files From 7adbaf0738da17133f77b9ea1cc6f9232735394b Mon Sep 17 00:00:00 2001 From: Serhii Ofii <132130496+SnowGlowedMountain@users.noreply.github.com> Date: Fri, 18 Oct 2024 20:12:40 +0300 Subject: [PATCH 2/2] Serhii milestone 2 fix (#48) * chores_lint_errors_and_path_issues * feature_db_py * fix_audio_issue * chores_lint_error * fix_openAPI_switching * chore_module_insert * fix_edit_collection_fix * fix_frontend_critical_update * fix_feedback2_translation_async * chore:lint * chore:loading_issue * chore:module --- linguaphoto/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linguaphoto/requirements.txt b/linguaphoto/requirements.txt index 3ffa6c2..f968e98 100644 --- a/linguaphoto/requirements.txt +++ b/linguaphoto/requirements.txt @@ -40,4 +40,5 @@ openai requests stripe -python-socketio[asgi] \ No newline at end of file +python-socketio +