diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 945e035..cfb1571 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "holderjs": "^2.9.9", "nth-check": "^2.1.1", "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", "react-icons": "^5.3.0", @@ -40,6 +41,7 @@ "@fortawesome/react-fontawesome": "^0.2.2", "@react-oauth/google": "^0.12.1", "@types/jest": "^29.5.12", + "@types/react-beautiful-dnd": "^13.1.8", "axios": "^1.7.2", "babel-eslint": "*", "bootstrap": "^5.3.3", @@ -4844,6 +4846,15 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -4995,6 +5006,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", @@ -5003,6 +5023,17 @@ "@types/react": "*" } }, + "node_modules/@types/react-redux": { + "version": "7.1.33", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", + "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.11", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", @@ -7307,6 +7338,14 @@ "postcss": "^8.4" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -10551,6 +10590,19 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/holderjs": { "version": "2.9.9", "resolved": "https://registry.npmjs.org/holderjs/-/holderjs-2.9.9.tgz", @@ -15451,6 +15503,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -18051,6 +18108,11 @@ "performance-now": "^2.1.0" } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -18132,6 +18194,24 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-bootstrap": { "version": "2.10.4", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.4.tgz", @@ -18339,6 +18419,30 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "dev": true }, + "node_modules/react-redux": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -20027,6 +20131,14 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -22553,6 +22665,11 @@ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -23534,6 +23651,14 @@ "node": ">=0.10.0" } }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8e2c971..0925a59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "holderjs": "^2.9.9", "nth-check": "^2.1.1", "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", "react-icons": "^5.3.0", @@ -57,6 +58,7 @@ "@fortawesome/react-fontawesome": "^0.2.2", "@react-oauth/google": "^0.12.1", "@types/jest": "^29.5.12", + "@types/react-beautiful-dnd": "^13.1.8", "axios": "^1.7.2", "babel-eslint": "*", "bootstrap": "^5.3.3", diff --git a/frontend/src/components/Audio.tsx b/frontend/src/components/Audio.tsx new file mode 100644 index 0000000..8fa86b6 --- /dev/null +++ b/frontend/src/components/Audio.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + FaPause, + FaPlay, + FaStop, + FaVolumeDown, + FaVolumeUp, +} from "react-icons/fa"; +import { Image } from "types/model"; + +interface AudioPlayerProps { + currentImage: Image; + index: number; +} + +const AudioPlayer: React.FC = ({ currentImage, index }) => { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [playbackRate, setPlaybackRate] = useState(0.9); + const [volume, setVolume] = useState(1); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + const togglePlayPause = () => { + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play(); + } + setIsPlaying(!isPlaying); + } + }; + + const stopAudio = () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + setIsPlaying(false); + setCurrentTime(0); + } + }; + + const handleSpeedChange = (e: React.ChangeEvent) => { + const speed = parseFloat(e.target.value); + setPlaybackRate(speed); + if (audioRef.current) { + audioRef.current.playbackRate = speed; + } + }; + + const handleVolumeChange = (e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value); + setVolume(newVolume); + if (audioRef.current) { + audioRef.current.volume = newVolume; + } + }; + + const handleProgressChange = (e: React.ChangeEvent) => { + if (audioRef.current && isFinite(duration) && duration > 0) { + const newTime = (parseFloat(e.target.value) / 100) * duration; + + if (isFinite(newTime) && newTime >= 0 && newTime <= duration) { + audioRef.current.currentTime = newTime; + setCurrentTime(newTime); + } + } + }; + + useEffect(() => { + const audio = audioRef.current; + + if (audio) { + audio.addEventListener("timeupdate", () => { + setCurrentTime(audio.currentTime); + setDuration(audio.duration || 0); // Ensure duration is valid + }); + + audio.addEventListener("ended", () => { + setIsPlaying(false); // Reset play status when audio ends + setCurrentTime(0); // Reset current time to 0 + }); + + // Clean up the event listeners on component unmount + return () => { + audio.removeEventListener("timeupdate", () => {}); + audio.removeEventListener("ended", () => {}); + }; + } + }, []); + + useEffect(() => { + if (audioRef.current) { + audioRef.current.load(); + } + }, [currentImage, index]); + + return ( +
+ + + {/* Custom Controls */} +
+
+ + + +
+
+ + +
+ +
+ + + +
+
+ + {/* Progress Bar */} +
+ +
+
+ ); +}; + +export default AudioPlayer; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index c69da57..28e583a 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,4 +1,3 @@ -import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; @@ -7,8 +6,4 @@ const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement, ); -root.render( - - - , -); +root.render(); diff --git a/frontend/src/pages/Collection.tsx b/frontend/src/pages/Collection.tsx index c44521a..2cbf22b 100644 --- a/frontend/src/pages/Collection.tsx +++ b/frontend/src/pages/Collection.tsx @@ -1,12 +1,14 @@ import { Api } from "api/api"; import axios, { AxiosInstance } from "axios"; +import AudioPlayer from "components/Audio"; 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 { useAlertQueue } from "hooks/alerts"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; import { Col, Row } from "react-bootstrap"; import { ArrowLeft, @@ -33,9 +35,11 @@ const CollectionPage: React.FC = () => { const [showUploadModal, setShowUploadModal] = useState(false); const [showDeleteImageModal, setShowDeleteImageModal] = useState(false); const [images, setImages] = useState | null>([]); - const audioRef = useRef(null); + const [reorderImageIds, setReorderImageIds] = useState | null>( + [], + ); const { addAlert } = useAlertQueue(); - const [deleteImageIndex, setDeleteImageIndex] = useState(""); + const [deleteImageId, setDeleteImageId] = useState(""); const apiClient: AxiosInstance = useMemo( () => axios.create({ @@ -48,6 +52,11 @@ const CollectionPage: React.FC = () => { }), [auth?.token], ); + useEffect(() => { + if (collection && collection.images) { + setReorderImageIds([...collection.images]); + } + }, [collection]); const apiClient1: AxiosInstance = useMemo( () => axios.create({ @@ -71,7 +80,16 @@ const CollectionPage: React.FC = () => { // Get translated images const translatedImages = useMemo(() => { // Get translated images - if (images) return images.filter((img) => img.is_translated); + if (images) { + const filter = images.filter((img) => img.is_translated); + const final_filter = reorderImageIds + ?.map((img) => { + const foundItem = filter.find((item) => item.id == img); + return foundItem ? foundItem : null; // Return `null` or skip + }) + .filter(Boolean); // Filters out `null` or `undefined` + if (final_filter) return final_filter; + } return []; }, [images]); @@ -105,11 +123,7 @@ const CollectionPage: React.FC = () => { asyncfunction(); } }, [collection?.id]); - useEffect(() => { - if (audioRef.current) { - audioRef.current.load(); - } - }, [currentTranscriptionIndex, currentImageIndex]); + const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); startLoading(); @@ -156,10 +170,12 @@ const CollectionPage: React.FC = () => { const handleSave = (e: React.FormEvent) => { e.preventDefault(); - if (collection) { + if (collection && reorderImageIds) { const asyncfunction = async () => { startLoading(); + collection.images = reorderImageIds; await API.editCollection(collection); + setCollection({ ...collection }); addAlert("The collection has been updated successfully!", "success"); stopLoading(); }; @@ -174,7 +190,11 @@ const CollectionPage: React.FC = () => { if (Image) { const new_images: Array | null = images; new_images?.push(Image); - if (new_images != undefined) setImages(new_images); + if (new_images != undefined) { + setImages(new_images); + collection.images.push(Image.id); + setCollection({ ...collection }); + } } } }; @@ -193,17 +213,34 @@ const CollectionPage: React.FC = () => { stopLoading(); } }; + // Inside your CollectionPage component + /* eslint-disable */ + const handleDragEnd = (result: any) => { + /* eslint-enable */ + if (!result.destination || !reorderImageIds) return; + const [removed] = reorderImageIds.splice(result.source.index, 1); + reorderImageIds.splice(result.destination.index, 0, removed); + setReorderImageIds([...reorderImageIds]); + + // Optionally, you can save the new order to your backend here + }; + const onShowDeleteImageModal = (id: string) => { - setDeleteImageIndex(id); + setDeleteImageId(id); setShowDeleteImageModal(true); }; const onDeleteImage = async () => { - if (deleteImageIndex) { + if (deleteImageId) { startLoading(); - await API.deleteImage(deleteImageIndex); + await API.deleteImage(deleteImageId); if (images) { - const filter = images.filter((image) => image.id != deleteImageIndex); + const filter = images.filter((image) => image.id !== deleteImageId); setImages(filter); + const filteredId = collection?.images.filter( + (image) => image !== deleteImageId, + ); + if (filteredId) setReorderImageIds(filteredId); + else setReorderImageIds([]); } setShowDeleteImageModal(false); addAlert("The image has been deleted!", "success"); @@ -297,12 +334,21 @@ const CollectionPage: React.FC = () => { - +
+ + +
{/* Upload Modal */} { - - {images ? ( - images.map((image) => { - return ( - - - - ); - }) - ) : ( - <> - )} - + {/* Drag and Drop for Images */} + + + {(provided) => ( + + {reorderImageIds ? ( + reorderImageIds.map((id, index) => { + const image = images?.find((item) => item.id === id); + if (image) { + return ( + + {(provided) => ( + + + + )} + + ); + } + return null; // Prevent rendering undefined + }) + ) : ( + <> + )} + {provided.placeholder} {/* Important for drag and drop */} + + )} + + + ); @@ -385,16 +462,10 @@ const CollectionPage: React.FC = () => { .translation }

- + {/* Navigation Buttons */}
diff --git a/linguaphoto/api/collection.py b/linguaphoto/api/collection.py index 18f60f7..935a469 100644 --- a/linguaphoto/api/collection.py +++ b/linguaphoto/api/collection.py @@ -59,7 +59,6 @@ async def editcollection( async with collection_crud: await collection_crud.edit_collection( collection.id, - user_id=user_id, updates={"title": collection.title, "description": collection.description, "images": collection.images}, ) return diff --git a/linguaphoto/crud/collection.py b/linguaphoto/crud/collection.py index 90d848d..177b2b1 100644 --- a/linguaphoto/crud/collection.py +++ b/linguaphoto/crud/collection.py @@ -20,8 +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, updates: dict) -> None: - await self._update_item(id, Collection, updates) + async def edit_collection(self, collection_id: str, updates: dict) -> None: + await self._update_item(collection_id, Collection, updates) async def delete_collection(self, collection_id: str) -> None: await self._delete_item(collection_id)