diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da2f963..79ac845 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,7 +44,6 @@ "@types/jest": "^29.5.12", "axios": "^1.7.2", "babel-eslint": "*", - "bootstrap": "^5.3.3", "eslint": "^8.0.0", "eslint-config-prettier": "*", "eslint-import-resolver-typescript": "*", @@ -56,7 +55,6 @@ "nodemon": "^3.1.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", - "react-bootstrap": "^2.10.2", "react-router-dom": "^6.23.1", "tailwindcss": "^3.4.10", "ts-jest": "^29.2.5", @@ -3602,31 +3600,6 @@ "node": ">= 8" } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.5.tgz", - "integrity": "sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==", - "dev": true, - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/@react-oauth/google": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.1.tgz", @@ -3646,48 +3619,6 @@ "node": ">=14.0.0" } }, - "node_modules/@restart/hooks": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", - "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", - "dev": true, - "dependencies": { - "dequal": "^2.0.3" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@restart/ui": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.8.0.tgz", - "integrity": "sha512-xJEOXUOTmT4FngTmhdjKFRrVVF0hwCLNPdatLCHkyS4dkiSK12cEu1Y0fjxktjJrdst9jJIc5J6ihMJCoWEN/g==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0", - "@popperjs/core": "^2.11.6", - "@react-aria/ssr": "^3.5.0", - "@restart/hooks": "^0.4.9", - "@types/warning": "^3.0.0", - "dequal": "^2.0.3", - "dom-helpers": "^5.2.0", - "uncontrollable": "^8.0.1", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - } - }, - "node_modules/@restart/ui/node_modules/uncontrollable": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", - "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", - "dev": true, - "peerDependencies": { - "react": ">=16.14.0" - } - }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -4055,15 +3986,6 @@ "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", - "dev": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -4645,15 +4567,6 @@ "@types/react": "*" } }, - "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", - "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", - "dev": true, - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -4725,12 +4638,6 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, - "node_modules/@types/warning": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", - "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", - "dev": true - }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", @@ -6142,25 +6049,6 @@ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, - "node_modules/bootstrap": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -6437,12 +6325,6 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==" }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "dev": true - }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -7542,16 +7424,6 @@ "utila": "~0.4" } }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -16049,25 +15921,6 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types-extra": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", - "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", - "dev": true, - "dependencies": { - "react-is": "^16.3.2", - "warning": "^4.0.0" - }, - "peerDependencies": { - "react": ">=0.14.0" - } - }, - "node_modules/prop-types-extra/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==", - "dev": true - }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -16359,36 +16212,6 @@ "uuid": "bin/uuid" } }, - "node_modules/react-bootstrap": { - "version": "2.10.4", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.4.tgz", - "integrity": "sha512-W3398nBM2CBfmGP2evneEO3ZZwEMPtHs72q++eNw60uDGDAdiGn0f9yNys91eo7/y8CTF5Ke1C0QO8JFVPU40Q==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.24.7", - "@restart/hooks": "^0.4.9", - "@restart/ui": "^1.6.9", - "@types/react-transition-group": "^4.4.6", - "classnames": "^2.3.2", - "dom-helpers": "^5.2.1", - "invariant": "^2.2.4", - "prop-types": "^15.8.1", - "prop-types-extra": "^1.1.0", - "react-transition-group": "^4.4.5", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" - }, - "peerDependencies": { - "@types/react": ">=16.14.8", - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/react-bootstrap-icons": { "version": "1.11.4", "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.11.4.tgz", @@ -17988,22 +17811,6 @@ "node": ">=10" } }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -20494,21 +20301,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/uncontrollable": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", - "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.6.3", - "@types/react": ">=16.9.11", - "invariant": "^2.2.4", - "react-lifecycles-compat": "^3.0.4" - }, - "peerDependencies": { - "react": ">=15.0.0" - } - }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -20734,15 +20526,6 @@ "makeerror": "1.0.12" } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dev": true, - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1b0c51a..5cb1a8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -61,7 +61,6 @@ "@types/jest": "^29.5.12", "axios": "^1.7.2", "babel-eslint": "*", - "bootstrap": "^5.3.3", "eslint": "^8.0.0", "eslint-config-prettier": "*", "eslint-import-resolver-typescript": "*", @@ -73,7 +72,6 @@ "nodemon": "^3.1.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", - "react-bootstrap": "^2.10.2", "react-router-dom": "^6.23.1", "tailwindcss": "^3.4.10", "ts-jest": "^29.2.5", diff --git a/frontend/src/App.css b/frontend/src/App.css index 56f0752..43ca4da 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -4,5 +4,93 @@ @tailwind utilities; body { - font-family: "Noto Serif SC", serif; /* Use Noto Serif Simplified Chinese font */ + font-family: "Noto Serif SC", serif; + /* Use Noto Serif Simplified Chinese font */ +} + +.perspective { + perspective: 1000px; +} + +.transform-style-preserve-3d { + transform-style: preserve-3d; +} + +.backface-hidden { + backface-visibility: hidden; +} + +.group-hover\:rotate-y-30:hover, +.rotate-y-30 { + transform: rotateY(-30deg); + /* Rotate the element on hover */ +} + +.rotate-y-90 { + transform: rotateY(-90deg) !important; +} + +.rotate-y-180 { + transform: rotateY(180deg); +} + +.rotate-left { + transform-origin: left; + /* Set transform origin to the left edge */ +} + +@layer base { + @layer base { + /* Global input customization */ + input, + textarea, + select { + @apply border border-gray-300 rounded-lg px-4 py-2 bg-white text-black placeholder-gray-400 transition duration-200 ease-in-out; + } + + /* Hover and focus states for inputs */ + input:hover, + textarea:hover, + select:hover { + @apply border-gray-400; + } + + input:focus, + textarea:focus, + select:focus { + @apply border-blue-500 ring ring-blue-200 outline-none; + } + + /* Disabled input */ + input:disabled, + textarea:disabled, + select:disabled { + @apply bg-gray-200 cursor-not-allowed opacity-50; + } + + /* Additional customization for textareas */ + textarea { + @apply resize-none; + /* Prevents resizing */ + } + + /* Button styling */ + button { + @apply bg-blue-500 text-white px-6 py-2 rounded-lg transition duration-200 ease-in-out; + } + + /* Hover and focus states for buttons */ + button:hover { + @apply bg-blue-600; + } + + button:focus { + @apply ring ring-blue-300 outline-none; + } + + /* Disabled button */ + button:disabled { + @apply bg-gray-500 cursor-not-allowed opacity-50; + } + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d5175f8..b9cbbfe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ -import "bootstrap/dist/css/bootstrap.min.css"; +import Content from "components/HOC/Content"; import LoadingMask from "components/LoadingMask"; -import TopNavbar from "components/nav/TopNavbar"; +import Navbar from "components/nav/Navbar"; import NotFoundRedirect from "components/NotFoundRedirect"; import { AuthProvider } from "contexts/AuthContext"; import { LoadingProvider } from "contexts/LoadingContext"; @@ -14,7 +14,6 @@ import NotFound from "pages/NotFound"; import SubscriptionTypePage from "pages/SubscriptioinType"; import SubscriptionCancelPage from "pages/Subscription"; import PrivateRoute from "ProtectedRoute"; -import { Container } from "react-bootstrap"; import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; import "./App.css"; @@ -26,7 +25,7 @@ const App = () => { <AuthProvider> <AlertQueueProvider> <AlertQueue> - <Container> + <Content> <Routes> <Route path="/" element={<Home />} /> <Route path="/404" element={<NotFound />} /> @@ -82,8 +81,9 @@ const App = () => { /> <Route path="*" element={<NotFoundRedirect />} /> </Routes> - </Container> - <TopNavbar /> + </Content> + <Navbar /> + {/* <TopNavbar /> */} </AlertQueue> </AlertQueueProvider> </AuthProvider> diff --git a/frontend/src/ProtectedRoute.tsx b/frontend/src/ProtectedRoute.tsx index 438242c..42bfee8 100644 --- a/frontend/src/ProtectedRoute.tsx +++ b/frontend/src/ProtectedRoute.tsx @@ -11,20 +11,19 @@ const PrivateRoute: React.FC<PrivateRouteProps> = ({ element, requiredSubscription = false, }) => { - const { auth } = useAuth(); + const { auth, is_auth } = useAuth(); const location = useLocation(); const navigate = useNavigate(); useEffect(() => { - if (auth) - if (!auth?.is_auth) { - // Redirect to login if not authenticated - navigate("/login", { replace: true, state: { from: location } }); - } else if (requiredSubscription && !auth?.is_subscription) { - // Redirect to subscription page if subscription is required and not active - navigate("/subscription_type", { replace: true }); - } - }, [auth, requiredSubscription, navigate, location]); + if (!is_auth) { + // Redirect to login if not authenticated + navigate("/login", { replace: true, state: { from: location } }); + } else if (auth && requiredSubscription && !auth?.is_subscription) { + // Redirect to subscription page if subscription is required and not active + navigate("/subscription_type", { replace: true }); + } + }, [auth, is_auth, requiredSubscription, navigate, location]); if (!auth?.is_auth || (requiredSubscription && !auth?.is_subscription)) { // Render nothing while redirecting diff --git a/frontend/src/components/Audio.tsx b/frontend/src/components/Audio.tsx index 5dea933..9d92b1f 100644 --- a/frontend/src/components/Audio.tsx +++ b/frontend/src/components/Audio.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; +import { CaretLeft, CaretRight } from "react-bootstrap-icons"; import { FaPause, FaPlay, @@ -9,15 +10,22 @@ import { import { Image } from "types/model"; interface AudioPlayerProps { - currentImage: Image; - index: number; + currentImage: Image; // current image + index: number; // current transcription index + handleTranscriptionNext: () => void; //next transcript + handleTranscriptionPrev: () => void; //prev transcript } -const AudioPlayer: React.FC<AudioPlayerProps> = ({ currentImage, index }) => { - const audioRef = useRef<HTMLAudioElement>(null); +const AudioPlayer: React.FC<AudioPlayerProps> = ({ + currentImage, + index, + handleTranscriptionNext, + handleTranscriptionPrev, +}) => { + const audioRef = useRef<HTMLAudioElement>(null); //audio const [isPlaying, setIsPlaying] = useState(false); - const [playbackRate, setPlaybackRate] = useState(1); - const [volume, setVolume] = useState(1); + const [playbackRate, setPlaybackRate] = useState(1); //speed + const [volume, setVolume] = useState(1); // volumn size const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); @@ -82,13 +90,15 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ currentImage, index }) => { setCurrentTime(0); // Reset current time to 0 }); + audio.addEventListener("ended", handleTranscriptionNext); + // Clean up the event listeners on component unmount return () => { audio.removeEventListener("timeupdate", () => {}); audio.removeEventListener("ended", () => {}); }; } - }, []); + }, [handleTranscriptionNext]); useEffect(() => { if (audioRef.current) { @@ -98,7 +108,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ currentImage, index }) => { }, [currentImage, index]); return ( - <div className="mt-4 w-full text-center bg-gray-200 dark:bg-gray-600 px-4 py-1 rounded-md"> + <div className="mt-2 w-full text-center bg-gray-12 px-4 py-1 rounded-md"> <audio ref={audioRef}> <source src={currentImage.transcriptions[index].audio_url} @@ -110,6 +120,17 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ currentImage, index }) => { {/* Custom Controls */} <div className="flex items-center justify-center mt-4 space-x-6"> <div className="flex gap-3"> + <button + className={`flex-1 flex justify-center p-2 rounded-full ${ + index === 0 + ? "bg-gray-300 text-gray-600 cursor-not-allowed" + : "bg-gray-500 text-white hover:bg-gray-400" + }`} + onClick={handleTranscriptionPrev} + disabled={index === 0} + > + <CaretLeft /> + </button> <button onClick={togglePlayPause} className="p-2 bg-blue-500 text-white rounded-full hover:bg-blue-600" @@ -123,6 +144,21 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ currentImage, index }) => { > <FaStop /> </button> + <button + className={`flex justify-center p-2 rounded-full ${ + index === currentImage.transcriptions.length - 1 || + currentImage.transcriptions.length === 0 + ? "bg-gray-300 text-gray-600 cursor-not-allowed" + : "bg-gray-500 text-white hover:bg-gray-400" + }`} + onClick={handleTranscriptionNext} + disabled={ + index === currentImage.transcriptions.length - 1 || + currentImage.transcriptions.length === 0 + } + > + <CaretRight /> + </button> </div> <div className="flex items-center"> <label htmlFor="speed" className="mr-2"> diff --git a/frontend/src/components/Book.tsx b/frontend/src/components/Book.tsx new file mode 100644 index 0000000..0c6cf8d --- /dev/null +++ b/frontend/src/components/Book.tsx @@ -0,0 +1,91 @@ +import bookplaceholder from "images/bookplaceholder.png"; +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +type BookProps = { + id: string; + title: string; + description: string; + featured_image: string; + is_editable?: boolean; + onDelete?: (id: string) => void; +}; + +const Book: React.FC<BookProps> = ({ + id, + title, + description, + featured_image, + onDelete, + is_editable, +}) => { + const [isOpen, setIsOpen] = useState<boolean>(false); + const navigate = useNavigate(); + const toggleOpen = () => setIsOpen(!isOpen); + const handleOpen = () => { + navigate(`/collection/${id}`); + }; + const handleEdit = () => { + navigate(`/collection/${id}?Action=edit`); + }; + const handleDelete = () => { + if (onDelete) onDelete(id); + }; + return ( + <div + className="relative group perspective w-48 h-64 relative cursor-pointer shadow-xl" + onClick={toggleOpen} + > + {/* Second page */} + <div className="absolute top-0 w-full h-full bg-gray-100 shadow-lg rounded-sm"> + <div className="p-4 flex flex-col justify-between items-center text-gray-700 text-center h-full"> + <div> + <h2 className="text-lg font-bold mb-2">{title}</h2> + <p className="text-sm line-clamp-5">{description}</p> + </div> + {is_editable ? ( + <div className="flex flex-col gap-2"> + <span className="hover:text-blue-700" onClick={handleOpen}> + Open + </span> + <span className="hover:text-blue-700" onClick={handleEdit}> + Edit + </span> + <span className="hover:text-red-700" onClick={handleDelete}> + Delete + </span> + </div> + ) : ( + <span className="hover:text-blue-700" onClick={handleOpen}> + Open + </span> + )} + </div> + </div> + {/* Book Wrapper */} + <div + className={`relative w-full h-full transform-style-preserve-3d transition-transform duration-700 group-hover:rotate-y-30 rotate-left rounded-sm ${isOpen ? "rotate-y-90" : ""}`} + > + {/* Front Cover */} + <div className="absolute w-full h-full bg-gray-800 text-white shadow-lg backface-hidden rounded-sm"> + <div className="h-full flex flex-col items-center justify-center relative"> + <img + src={featured_image || bookplaceholder} + alt={title} + className="object-cover w-full h-full rounded-sm" + /> + <div className="absolute top-4 bg-gray-12/70 mt-7 mx-5 p-2 max-h-52 overflow-hidden"> + <h2 className="text-md text-center">{title}</h2> + </div> + </div> + </div> + {/* Back Cover */} + <div className="absolute w-full h-full bg-gray-800 shadow-lg rotate-y-180 backface-hidden"></div> + {/* Spine of the Book */} + <div className="absolute h-full w-4 bg-gray-700/30 left-0 top-0 rounded-l-sm shadow-md"></div> + </div> + </div> + ); +}; + +export default Book; diff --git a/frontend/src/components/BookSkeleton.tsx b/frontend/src/components/BookSkeleton.tsx new file mode 100644 index 0000000..daec755 --- /dev/null +++ b/frontend/src/components/BookSkeleton.tsx @@ -0,0 +1,40 @@ +type BookSkeletonProps = { + is_light: boolean; +}; +const BookSkeleton: React.FC<BookSkeletonProps> = ({ is_light }) => { + const items = Array(5).fill(null); + return ( + <> + {is_light + ? items.map((_, index) => { + return ( + <div + className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 xl:w-1/5 2xl:w-1/6" + key={index} + > + <div className="bg-gray-5 w-48 h-64 rounded-lg animate-pulse p-8"> + <div className="bg-gray-8 w-full h-4 rounded-lg animate-pulse" /> + <div className="bg-gray-8 w-full h-4 rounded-lg animate-pulse mt-8" /> + <div className="bg-gray-8 w-full h-4 rounded-lg animate-pulse mt-2" /> + </div> + </div> + ); + }) + : items.map((_, index) => { + return ( + <div + className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 xl:w-1/5 2xl:w-1/6" + key={index} + > + <div className="bg-gray-11 w-48 h-64 rounded-lg animate-pulse p-8"> + <div className="bg-gray-12 w-full h-4 rounded-lg animate-pulse" /> + <div className="bg-gray-12 w-full h-4 rounded-lg animate-pulse mt-8" /> + <div className="bg-gray-12 w-full h-4 rounded-lg animate-pulse mt-2" /> + </div> + </div> + ); + })} + </> + ); +}; +export default BookSkeleton; diff --git a/frontend/src/components/HOC/Container.tsx b/frontend/src/components/HOC/Container.tsx new file mode 100644 index 0000000..af3213a --- /dev/null +++ b/frontend/src/components/HOC/Container.tsx @@ -0,0 +1,10 @@ +import React, { ReactNode } from "react"; +interface ContainerProps { + children: ReactNode; +} +const Container: React.FC<ContainerProps> = ({ children }) => { + return ( + <div className="mx-4 sm:mx-6 md:mx-10 xl:mx-16 relative">{children}</div> + ); +}; +export default Container; diff --git a/frontend/src/components/HOC/Content.tsx b/frontend/src/components/HOC/Content.tsx new file mode 100644 index 0000000..5be80f9 --- /dev/null +++ b/frontend/src/components/HOC/Content.tsx @@ -0,0 +1,15 @@ +import React, { ReactNode } from "react"; +interface ContentProps { + children: ReactNode; +} +const Content: React.FC<ContentProps> = ({ children }) => { + return ( + <div + className="h-screen mx-4 sm:mx-6 md:mx-10 xl:mx-16 pb-4" + style={{ paddingTop: "68px" }} + > + {children} + </div> + ); +}; +export default Content; diff --git a/frontend/src/components/HOC/DraggableWrapper.tsx b/frontend/src/components/HOC/DraggableWrapper.tsx new file mode 100644 index 0000000..984349a --- /dev/null +++ b/frontend/src/components/HOC/DraggableWrapper.tsx @@ -0,0 +1,78 @@ +import React, { ReactNode, useEffect, useRef, useState } from "react"; + +interface DraggableWrapperProps { + children: ReactNode; + handleSelector: string; // Selector for the draggable handle +} + +const DraggableWrapper: React.FC<DraggableWrapperProps> = ({ + children, + handleSelector, +}) => { + const [position, setPosition] = useState({ x: 50, y: 150 }); + const [isDragging, setIsDragging] = useState(false); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const ref = useRef<HTMLDivElement>(null); + const animationFrameRef = useRef<number | null>(null); + + const onMouseDown = (e: React.MouseEvent) => { + const handleElement = (e.target as HTMLElement).closest(handleSelector); + + if (handleElement) { + const element = ref.current; + + if (element) { + const rect = element.getBoundingClientRect(); + // Calculate the offset between the cursor and the element's top-left corner + setOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + setIsDragging(true); + } + } + }; + + const onMouseMove = (e: MouseEvent) => { + if (isDragging && animationFrameRef.current === null) { + // Throttle with requestAnimationFrame for better performance + animationFrameRef.current = requestAnimationFrame(() => { + setPosition({ x: e.clientX - offset.x, y: e.clientY - offset.y }); + animationFrameRef.current = null; + }); + } + }; + + const onMouseUp = () => { + setIsDragging(false); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + } else { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + } + + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [isDragging, offset]); + + return ( + <div + ref={ref} + className="fixed shadow-xl" + style={{ top: `${position.y}px`, left: `${position.x}px` }} + onMouseDown={onMouseDown} + > + {children} + </div> + ); +}; + +export default DraggableWrapper; diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx new file mode 100644 index 0000000..ac42f30 --- /dev/null +++ b/frontend/src/components/Logo.tsx @@ -0,0 +1,14 @@ +import smallLogo from "images/small-logo.png"; + +const Logo = () => { + return ( + <div className="flex items-center space-x-2 select-none"> + <img src={smallLogo} alt="K Scale Logo" className="h-6 invert" /> + <span className="text-lg font-bold text-gray-1 font-orbitron tracking-wider"> + Linguaphoto + </span> + </div> + ); +}; + +export default Logo; diff --git a/frontend/src/components/UploadContent.tsx b/frontend/src/components/UploadContent.tsx index 7dfd505..d0b1cfb 100644 --- a/frontend/src/components/UploadContent.tsx +++ b/frontend/src/components/UploadContent.tsx @@ -80,12 +80,12 @@ const UploadContent: FC<UploadContentProps> = ({ onUpload }) => { alt="" className="w-full h-32 object-cover rounded-lg border border-gray-300 dark:border-gray-700" /> - <button - className="absolute top-2 right-2 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 transition-colors duration-200" + <span + className="absolute top-2 right-2 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 transition-colors duration-200 cursor-pointer" onClick={() => onImageRemove(index)} > <XCircleFill size={24} /> - </button> + </span> </div> )) ) : ( diff --git a/frontend/src/components/auth/GoogleAuthComponent.tsx b/frontend/src/components/auth/GoogleAuthComponent.tsx deleted file mode 100644 index de010f7..0000000 --- a/frontend/src/components/auth/GoogleAuthComponent.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { faGoogle } from "@fortawesome/free-brands-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { GoogleOAuthProvider, useGoogleLogin } from "@react-oauth/google"; -import { humanReadableError } from "constants/backend"; -import { useAlertQueue } from "hooks/alerts"; -import { useAuthentication } from "hooks/auth"; -import { useEffect, useState } from "react"; -import { Button } from "react-bootstrap"; - -const GOOGLE_CLIENT_ID = process.env.REACT_APP_GOOGLE_CLIENT_ID; - -interface UserLoginResponse { - token: string; - token_type: string; -} - -const GoogleAuthComponentInner = () => { - const [credential, setCredential] = useState<string | null>(null); - const [disableButton, setDisableButton] = useState(false); - - const { setApiKey, api } = useAuthentication(); - const { addAlert } = useAlertQueue(); - - useEffect(() => { - (async () => { - if (credential !== null) { - try { - const response = await api.post<UserLoginResponse>("/users/google", { - token: credential, - }); - setApiKey(response.data.token); - } catch (error) { - addAlert(humanReadableError(error), "error"); - } finally { - setCredential(null); - setDisableButton(false); - } - } - })(); - }, [credential, setApiKey, api, addAlert]); - - const login = useGoogleLogin({ - onSuccess: (tokenResponse) => { - const returnedCredential = tokenResponse.access_token; - if (returnedCredential) { - setCredential(returnedCredential); - } else { - addAlert("Failed to login using Google OAuth.", "error"); - } - }, - onError: () => { - addAlert("Failed to login using Google OAuth.", "error"); - setDisableButton(false); - }, - onNonOAuthError: () => { - addAlert("Failed to login using Google OAuth.", "error"); - setDisableButton(false); - }, - }); - - return ( - <Button - onClick={() => { - setDisableButton(true); - login(); - }} - disabled={disableButton || credential !== null} - variant="primary" - > - Sign In with Google - <FontAwesomeIcon icon={faGoogle} style={{ marginLeft: 10 }} /> - </Button> - ); -}; - -const GoogleAuthComponent = () => { - // Fatal error if GOOGLE_CLIENT_ID is not set - if (GOOGLE_CLIENT_ID === undefined) { - throw new Error(`REACT_APP_GOOGLE_CLIENT_ID is ${GOOGLE_CLIENT_ID}.`); - } - - return ( - <GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}> - <GoogleAuthComponentInner /> - </GoogleOAuthProvider> - ); -}; - -export default GoogleAuthComponent; diff --git a/frontend/src/components/auth/RequireAuthentication.tsx b/frontend/src/components/auth/RequireAuthentication.tsx deleted file mode 100644 index d51a6ca..0000000 --- a/frontend/src/components/auth/RequireAuthentication.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useAuthentication } from "hooks/auth"; -import React from "react"; -import { useNavigate } from "react-router-dom"; - -interface Props { - children: React.ReactNode; -} - -const RequireAuthentication = (props: Props) => { - const { children } = props; - const { isAuthenticated } = useAuthentication(); - - const navigate = useNavigate(); - - if (!isAuthenticated) { - navigate("/"); - } - - return children; -}; - -export default RequireAuthentication; diff --git a/frontend/src/components/card.tsx b/frontend/src/components/card.tsx index dd4e07d..02d4ba3 100644 --- a/frontend/src/components/card.tsx +++ b/frontend/src/components/card.tsx @@ -45,7 +45,7 @@ const CardItem: React.FC<CardItemProps> = (cardprops) => { Edit </button> <button - className="bg-red-700 text-white flex justify-content-center items-center w-8 h-8 rounded" + className="bg-red-700 text-white flex justify-center items-center w-8 h-8 rounded" onClick={handleDelete} > <TrashFill size={18} /> diff --git a/frontend/src/components/collection/Edit.tsx b/frontend/src/components/collection/Edit.tsx new file mode 100644 index 0000000..c9448e7 --- /dev/null +++ b/frontend/src/components/collection/Edit.tsx @@ -0,0 +1,350 @@ +import { Api } from "api/api"; +import axios, { AxiosInstance } from "axios"; +import Book from "components/Book"; +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 { useEffect, useMemo, useState } from "react"; +import { ListManager } from "react-beautiful-dnd-grid"; +import { Collection, Image } from "types/model"; +type CollectionEditProps = { + collection: Collection; + setCollection: React.Dispatch<React.SetStateAction<Collection | undefined>>; +}; +const CollectionEdit: React.FC<CollectionEditProps> = ({ + collection, + setCollection, +}) => { + const [featured_image, setFeaturedImage] = useState<string>(""); + const [title, setTitle] = useState<string>(""); + const [description, setDescription] = useState<string>(""); + const { auth, client } = useAuth(); + const { startLoading, stopLoading } = useLoading(); + const [showUploadModal, setShowUploadModal] = useState(false); + const [showDeleteImageModal, setShowDeleteImageModal] = useState(false); + const [images, setImages] = useState<Array<Image> | undefined>([]); + const [reorderImageIds, setReorderImageIds] = useState<Array<string> | null>( + [], + ); + const { addAlert } = useAlertQueue(); + const [deleteImageId, setDeleteImageId] = useState<string>(""); + useEffect(() => { + if (collection) { + setTitle(collection.title); + setDescription(collection.description); + setFeaturedImage(collection.featured_image); + setReorderImageIds([...collection.images]); + const asyncfunction = async () => { + const { data: images, error } = await client.GET("/get_images", { + params: { query: { collection_id: collection.id } }, + }); + if (error) addAlert(error.detail?.toString(), "error"); + else setImages(images); + }; + 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], + ); + const apiClient1: AxiosInstance = useMemo( + () => + axios.create({ + baseURL: process.env.REACT_APP_BACKEND_URL, + timeout: 1000000, + headers: { + "Content-Type": "multipart/form-data", + Authorization: `Bearer ${auth?.token}`, + }, + }), + [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(); + // Use e.nativeEvent to access the native event + const target = e.nativeEvent as SubmitEvent; + const submitButton = target.submitter as HTMLButtonElement; + // Check if the submitButton exists and get its value + const action = submitButton ? submitButton.value : ""; + switch (action) { + case "save": + if (collection && reorderImageIds) { + const asyncfunction = async () => { + startLoading(); + collection.images = reorderImageIds; + const { error } = await client.POST("/edit_collection", { + body: { ...collection, featured_image, title, description }, + }); + if (error) addAlert(error.detail?.toString(), "error"); + else { + setCollection({ + ...collection, + featured_image, + title, + description, + }); + addAlert( + "The collection has been updated successfully!", + "success", + ); + } + stopLoading(); + }; + asyncfunction(); + } + break; + case "publish": + if (collection) { + const { error } = await client.POST("/publish_collection", { + body: { id: collection.id, flag: !collection.publish_flag }, + }); + if (error) addAlert(error.detail?.toString(), "error"); + else { + setCollection({ + ...collection, + publish_flag: !collection.publish_flag, + }); + addAlert( + "The collection has been updated successfully!", + "success", + ); + } + } + break; + } + }; + const onShowDeleteImageModal = (id: string) => { + setDeleteImageId(id); + setShowDeleteImageModal(true); + }; + const handleUpload = async (file: File) => { + if (collection) { + startLoading(); + const new_image = await API_Uploader.uploadImage(file, collection?.id); + stopLoading(); + if (new_image) { + if (collection.images.length == 0 || images == undefined) { + // no images + setImages([new_image]); + setFeaturedImage(new_image.image_url); + } else { + // images exist + images.push(new_image); + setImages([...images]); + } + collection.images.push(new_image.id); + setReorderImageIds([...collection.images]); + setCollection({ ...collection }); + } + } + }; + 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 onDeleteImage = async () => { + if (deleteImageId) { + startLoading(); + const { error } = await client.GET("/delete_image", { + params: { query: { id: deleteImageId } }, + }); + if (error) addAlert(error.detail?.toString(), "error"); + else if (images) { + const new_images_ID = collection.images.filter( + (image) => image !== deleteImageId, + ); + collection.images = new_images_ID; + const new_images = images.filter((image) => image.id !== deleteImageId); + setImages(new_images); + setReorderImageIds([...collection.images]); + setCollection({ ...collection }); + addAlert("The image has been deleted!", "success"); + } + setShowDeleteImageModal(false); + stopLoading(); + } + }; + // Inside your CollectionPage component + /* eslint-disable */ + const handleDragEnd = (sourceIndex: number, destinationIndex: number) => { + /* eslint-enable */ + if (!reorderImageIds) return; + const [removed] = reorderImageIds.splice(sourceIndex, 1); + reorderImageIds.splice(destinationIndex, 0, removed); + setReorderImageIds([...reorderImageIds]); + //featured image + const image = images?.find((img) => img.id == reorderImageIds[0]); + if (image) setFeaturedImage(image?.image_url); + // Optionally, you can save the new order to your backend here + }; + return ( + <div className="flex flex-col rounded-md min-h-full bg-gray-3 p-24 gap-8"> + <h1 className="text-3xl text-gray-900">Edit Collection </h1> + <form + className="flex flex-col items-center gap-4 w-full" + onSubmit={handleSave} + > + <div className="flex flex-wrap w-full gap-4"> + <div className="flex flex-col flex-1 gap-3 h-64"> + <input + className="border p-2 w-full" + type="text" + placeholder="Title" + value={title} + onChange={(e) => setTitle(e.target.value)} + required + /> + <textarea + className="flex-1 border p-2 w-full" + placeholder="Description" + value={description} + onChange={(e) => setDescription(e.target.value)} + required + /> + </div> + <Book + title={title} + description={description} + id={collection.id} + featured_image={featured_image} + /> + </div> + <div className="flex justify-content-end w-full gap-2"> + <button + className="bg-blue-500 text-white w-35 p-2 rounded hover:bg-blue-600" + onClick={() => setShowUploadModal(true)} + > + Add Images + </button> + <button + className="bg-blue-500 text-white w-30 p-2 rounded hover:bg-blue-600" + disabled={ + collection.images.join() == reorderImageIds?.join() && + collection.title == title && + collection.description == description + } + type="submit" + name="action" + value="save" + > + Save Changes + </button> + <button + className="bg-blue-500 text-white w-30 p-2 rounded hover:bg-blue-600" + type="submit" + name="action" + value="publish" + > + {collection.publish_flag ? "Unpublish" : "Publish"} + </button> + </div> + </form> + {/* <div className="flex gap-4"> + <button + className="bg-blue-500 text-white w-35 p-2 rounded hover:bg-blue-600 disabled:bg-gray-600" + onClick={(e) => handleSave(e)} + disabled={reorderImageIds?.join() === collection.images.join()} + value="save" + > + Order Save + </button> + </div> */} + {/* reordering part */} + <div className="flex flex-wrap justify-start w-full"> + {reorderImageIds && images && ( + <ListManager + items={reorderImageIds} + direction="horizontal" + maxItems={3} + onDragEnd={handleDragEnd} + render={(id) => { + const image = images.find((item) => item.id === id); + return ( + <div className="w-full md:w-1/2 lg:w-1/3"> + {image ? ( + <ImageComponent + {...image} + handleTranslateOneImage={handleTranslateOneImage} + showDeleteModal={onShowDeleteImageModal} + /> + ) : ( + <ImageComponent + {...{ + id, + is_translated: false, + image_url: "", + transcriptions: [], + collection: collection.id, + }} + handleTranslateOneImage={handleTranslateOneImage} + showDeleteModal={onShowDeleteImageModal} + /> + )} + </div> + ); + }} + /> + )} + </div> + {/* Upload Modal */} + <Modal isOpen={showUploadModal} onClose={() => setShowUploadModal(false)}> + <UploadContent onUpload={handleUpload} /> + <div className="mt-5 flex justify-end space-x-2"> + <button + className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400" + onClick={() => setShowUploadModal(false)} + > + Close + </button> + </div> + </Modal> + <Modal + isOpen={showDeleteImageModal} + onClose={() => setShowDeleteImageModal(false)} + > + <div className="mt-5 flex justify-end space-x-2 gap-4 items-center"> + <span>Are you sure you want to delete the collection?</span> + <button + className="px-4 py-2 bg-red-700 text-gray-300 rounded hover:bg-red-800" + onClick={onDeleteImage} + > + Delete + </button> + <button + className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400" + onClick={() => setShowDeleteImageModal(false)} + > + Cancel + </button> + </div> + </Modal> + </div> + ); +}; +export default CollectionEdit; diff --git a/frontend/src/components/collection/New.tsx b/frontend/src/components/collection/New.tsx new file mode 100644 index 0000000..4ff6bc9 --- /dev/null +++ b/frontend/src/components/collection/New.tsx @@ -0,0 +1,62 @@ +import { useAuth } from "contexts/AuthContext"; +import { useLoading } from "contexts/LoadingContext"; +import { useAlertQueue } from "hooks/alerts"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +const CollectionNew: React.FC = () => { + const navigate = useNavigate(); + const { client } = useAuth(); + const { startLoading, stopLoading } = useLoading(); + const { addAlert } = useAlertQueue(); + const [title, setTitle] = useState<string>(""); + const [description, setDescription] = useState<string>(""); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + startLoading(); + const { data: collection, error } = await client.POST( + "/create_collection", + { body: { title, description } }, + ); + if (error) addAlert(error.detail?.toString(), "error"); + else 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(); + }; + + return ( + <div className="flex flex-col rounded-md h-full bg-gray-3 p-24 gap-8"> + <h1 className="text-3xl text-gray-900">New Collection</h1> + <form + className="flex flex-col items-end gap-4 w-full" + onSubmit={handleCreate} + > + <input + className="border p-2 w-full" + type="text" + placeholder="Title" + value={title} + onChange={(e) => setTitle(e.target.value)} + required + /> + <textarea + className="border p-2 w-full h-32" + placeholder="Description" + value={description} + onChange={(e) => setDescription(e.target.value)} + required + /> + <button + className="bg-blue-500 w-32 text-white p-2 rounded hover:bg-blue-600" + type="submit" + > + Create + </button> + </form> + </div> + ); +}; +export default CollectionNew; diff --git a/frontend/src/components/collection/View.tsx b/frontend/src/components/collection/View.tsx new file mode 100644 index 0000000..603d3c3 --- /dev/null +++ b/frontend/src/components/collection/View.tsx @@ -0,0 +1,170 @@ +import AudioPlayer from "components/Audio"; +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"; +type CollectionViewProps = { + collection: Collection; +}; +const CollectionView: React.FC<CollectionViewProps> = ({ collection }) => { + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [currentTranscriptionIndex, setCurrentTranscriptionIndex] = useState(0); + const [currentImage, setCurrentImage] = useState<Image | null>(null); + const [isLoading, setIsLoading] = useState<boolean>(true); + const { client } = useAuth(); + const { addAlert } = useAlertQueue(); + const [images, setImages] = useState<Array<Image> | 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 + }) + .filter(Boolean); // Filters out `null` or `undefined` + if (final_filter) return final_filter; + } + return []; + }, [images]); + + useEffect(() => { + if (translatedImages.length > 0) { + setCurrentImage(translatedImages[currentImageIndex]); + } + }, [currentImageIndex, translatedImages]); + + useEffect(() => { + if (collection) { + const asyncfunction = async () => { + const { data: images, error } = await client.GET("/get_images", { + params: { query: { collection_id: collection.id } }, + }); + if (error) addAlert(error.detail?.toString(), "error"); + else setImages(images); + setIsLoading(false); + }; + asyncfunction(); + } + }, [collection?.id]); + + // 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 + } + }; + + const handlePrev = () => { + if (currentImageIndex > 0) { + setCurrentImageIndex(currentImageIndex - 1); + setCurrentTranscriptionIndex(0); + window.scrollTo(0, 0); // This instantly jumps the viewport to the top + } + }; + + // Navigate transcriptions + const handleTranscriptionNext = () => { + if ( + currentImage?.transcriptions && + currentTranscriptionIndex < currentImage?.transcriptions.length - 1 + ) { + setCurrentTranscriptionIndex(currentTranscriptionIndex + 1); + } + }; + + const handleTranscriptionPrev = () => { + if (currentTranscriptionIndex > 0) { + setCurrentTranscriptionIndex(currentTranscriptionIndex - 1); + } + }; + const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => { + // 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 + } else { + handleNext(); // Clicked on the right side + } + }; + return ( + <div className="flex flex-col rounded-md h-full items-center bg-gray-0 gap-4 w-full"> + {isLoading ? ( + <div className="flex flex-col h-full w-full gap-4"> + <div className="bg-gray-3 w-full rounded-lg h-3/4 animate-pulse" /> + <div className="bg-gray-3 w-full rounded-lg h-1/4 animate-pulse" /> + </div> + ) : currentImage ? ( + <div className="flex flex-col align-items-center w-full"> + <div className="w-full absolute left-0"> + <img + draggable="false" + src={currentImage.image_url} + alt="Collection Image" + className="w-full select-none" + style={{ marginBottom: "230px" }} + onClick={handlePhotoClick} + /> + </div> + <div className="fixed bottom-0 left-0 w-full px-4 py-1 text-center bg-gray-1/30 backdrop-blur-lg"> + <Container> + {/* transcription */} + <div className="rounded-md bg-gray-12 p-2"> + <p className="mt-2 px-12"> + {currentImage.transcriptions.map((transcription, index) => { + return ( + <span + key={index} + className={ + index == currentTranscriptionIndex + ? "" + : "text-gray-400" + } + > + {transcription.text} + </span> + ); + })} + </p> + <p className="mt-2"> + { + currentImage.transcriptions[currentTranscriptionIndex] + .pinyin + } + </p> + <p className="mt-2"> + { + currentImage.transcriptions[currentTranscriptionIndex] + .translation + } + </p> + <AudioPlayer + currentImage={currentImage} + index={currentTranscriptionIndex} + handleTranscriptionNext={handleTranscriptionNext} + handleTranscriptionPrev={handleTranscriptionPrev} + /> + </div> + </Container> + </div> + </div> + ) : ( + <div className="p-24 flex h-full items-center"> + <h1 className="text-3xl text-gray-900"> + No translated images available. + </h1> + </div> + )} + </div> + ); +}; +export default CollectionView; diff --git a/frontend/src/components/image.tsx b/frontend/src/components/image.tsx index a28aacd..7f1f2b4 100644 --- a/frontend/src/components/image.tsx +++ b/frontend/src/components/image.tsx @@ -15,7 +15,6 @@ const ImageComponent: React.FC<ImageWithFunction> = ({ id, is_translated, image_url, - transcriptions, handleTranslateOneImage, showDeleteModal, }) => { @@ -31,11 +30,6 @@ const ImageComponent: React.FC<ImageWithFunction> = ({ <CheckCircleFill size={15} className="mr-2" /> <span>The image has been translated</span> </div> - <div className="absolute bottom-2 text-white bg-gray-800 py-1 px-3 mx-2 rounded"> - {transcriptions.map((transcription, index) => ( - <span key={index}>{transcription.text} </span> - ))} - </div> </> ) : ( <> diff --git a/frontend/src/components/modal.tsx b/frontend/src/components/modal.tsx index d5ef647..2622bb6 100644 --- a/frontend/src/components/modal.tsx +++ b/frontend/src/components/modal.tsx @@ -15,13 +15,13 @@ const Modal: FC<ModalProps> = ({ isOpen, onClose, children }) => { {/* Modal container */} <div className="bg-white p-8 rounded-2xl shadow-xl max-w-3xl w-full relative border border-gray-200 dark:bg-gray-800 dark:border-gray-700 dark:text-white"> {/* Close button in the top right corner */} - <button - className="absolute top-4 right-4 text-gray-600 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 transition-colors duration-200" + <div + className="absolute hover:bg-transparent top-6 right-8 text-gray-400 hover:text-red-400 transition-colors duration-200" onClick={onClose} aria-label="Close Modal" > <X size={28} /> {/* Close icon from react-bootstrap-icons */} - </button> + </div> {/* Modal content */} <div className="p-6">{children}</div> diff --git a/frontend/src/components/nav/Navbar.tsx b/frontend/src/components/nav/Navbar.tsx new file mode 100644 index 0000000..b0beaca --- /dev/null +++ b/frontend/src/components/nav/Navbar.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { FaBars } from "react-icons/fa"; +import { Link, useLocation } from "react-router-dom"; + +import Container from "components/HOC/Container"; +import Logo from "components/Logo"; +import Sidebar from "components/nav/Sidebar"; +import { useAuth } from "contexts/AuthContext"; +const Navbar = () => { + const { auth, signout } = useAuth(); + const [showSidebar, setShowSidebar] = useState<boolean>(false); + const location = useLocation(); + + const navItems = [ + { name: "Home", path: "/", isExternal: false }, + { name: "My Collections", path: "/collections", isExternal: false }, + { name: "Subscription", path: "/subscription", isExternal: false }, + ]; + + return ( + <> + { + <nav className="fixed w-full z-30 top-0 start-0 bg-gray-1/30 backdrop-blur-lg"> + <Container> + <div className="flex items-center justify-between py-2 font-medium"> + <Link + to="/" + className="flex items-center space-x-2 bg-gray-12 p-3 rounded-lg hover:bg-primary-9 transition-all duration-300" + > + <Logo /> + </Link> + <div className="hidden lg:flex items-center flex-grow justify-between ml-4"> + <div className="flex space-x-3 bg-gray-12 rounded-lg p-2 flex-grow justify-center"> + {navItems.map((item) => + item.isExternal ? ( + <a + key={item.name} + href={item.path} + className={`px-2 xl:px-3 py-2 rounded-md text-sm tracking-wide xl:tracking-widest text-gray-1 hover:bg-primary-9`} + target="_blank" + rel="noopener noreferrer" + > + {item.name} + </a> + ) : ( + <Link + key={item.name} + to={item.path} + className={`px-2 xl:px-3 py-2 rounded-md text-sm tracking-widest ${ + location.pathname === item.path + ? "bg-gray-11 text-gray-1" + : "text-gray-1 hover:bg-gray-1 hover:text-primary-9" + }`} + > + {item.name} + </Link> + ), + )} + </div> + <div className="flex items-center space-x-2 text-gray-1 bg-gray-12 rounded-lg p-2 ml-4 text-sm tracking-widest"> + {auth?.is_auth ? ( + <> + {/* <Link + to="/account" + className={`px-3 py-2 rounded-md hover:bg-gray-1 hover:text-primary-9 ${location.pathname === "/account" + ? "bg-gray-11 text-gray-1" + : "" + }`} + > + Account + </Link> */} + <Link + to="/login" + className="px-3 py-2 rounded-md hover:bg-primary-9" + onClick={signout} + > + Logout + </Link> + </> + ) : ( + <> + <Link + to="/login" + className="px-3 py-2 rounded-md hover:bg-gray-1 hover:text-primary-9" + > + Sign In + </Link> + </> + )} + </div> + </div> + <button + onClick={() => setShowSidebar(true)} + className="lg:hidden text-gray-300 hover:bg-gray-700 bg-gray-12 hover:text-white p-4 rounded-md text-sm" + > + <FaBars size={20} /> + </button> + </div> + </Container> + </nav> + } + <Sidebar show={showSidebar} onClose={() => setShowSidebar(false)} /> + </> + ); +}; + +export default Navbar; diff --git a/frontend/src/components/nav/TopNavbar.tsx b/frontend/src/components/nav/TopNavbar.tsx deleted file mode 100644 index 4f04e4f..0000000 --- a/frontend/src/components/nav/TopNavbar.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { useAuth } from "contexts/AuthContext"; -import { useTheme } from "hooks/theme"; -import { useState } from "react"; -import { Container, Nav, Navbar } from "react-bootstrap"; -import { MoonFill, SunFill } from "react-bootstrap-icons"; -import { - FaBars, - FaLock, - FaSignInAlt, - FaSignOutAlt, - FaThList, -} from "react-icons/fa"; -import { Link, useLocation } from "react-router-dom"; -import Sidebar from "./Sidebar"; - -const TopNavbar = () => { - const [showSidebar, setShowSidebar] = useState<boolean>(false); - const { theme, setTheme } = useTheme(); - const location = useLocation(); // To determine the active link - const { auth, signout } = useAuth(); - return ( - <> - <Navbar - className="fixed w-full top-0 z-50 bg-gray-100 dark:bg-gray-800 justify-content-between shadow-lg" - expand="lg" - style={{ height: "60px" }} // Set navbar height - > - <Container className="flex justify-between items-center h-full"> - <Navbar.Brand - as={Link} - to="/" - className="text-xl text-gray-900 dark:text-white" // Reduced font size - > - LinguaPhoto - </Navbar.Brand> - - <div className="flex gap-6 items-center"> - {/* Main Navigation Links */} - <div className="hidden sm:flex gap-6"> - {" "} - {/* Hidden on small screens */} - {auth?.is_auth ? ( - <> - <Nav.Link - as={Link} - to="/collections" - className={`flex items-center gap-2 px-3 py-2 text-sm transition-colors rounded-md ${ - location.pathname === "/collections" - ? "text-blue-600 dark:text-blue-400 font-semibold" - : "text-gray-800 dark:text-gray-300" - } hover:text-blue-500 dark:hover:text-blue-300`} - > - <FaThList /> <span>Collections</span> - </Nav.Link> - - <Nav.Link - as={Link} - to="/subscription" - className={`flex items-center gap-2 px-3 py-2 text-sm transition-colors rounded-md ${ - location.pathname === "/subscription" - ? "text-blue-600 dark:text-blue-400 font-semibold" - : "text-gray-800 dark:text-gray-300" - } hover:text-blue-500 dark:hover:text-blue-300`} - > - <FaLock /> <span>Subscription</span> - </Nav.Link> - <Nav.Link - as={Link} - to="/login" - className="flex items-center gap-2 px-3 py-2 text-sm transition-colors rounded-md hover:text-blue-500 dark:hover:text-blue-300" - onClick={() => { - // Handle logout logic here - signout(); - }} - > - <FaSignOutAlt /> <span>Logout</span> - </Nav.Link> - </> - ) : ( - <Nav.Link - as={Link} - to="/login" - className={`flex items-center gap-2 px-3 py-2 text-sm transition-colors rounded-md ${ - location.pathname === "/login" - ? "text-blue-600 dark:text-blue-400 font-semibold" - : "text-gray-800 dark:text-gray-300" - } hover:text-blue-500 dark:hover:text-blue-300`} - onClick={() => { - // Handle logout logic here - signout(); - }} - > - <FaSignInAlt /> <span>Login / Sign Up</span> - </Nav.Link> - )} - </div> - - {/* Theme Toggle and Sidebar */} - <div className="flex items-center gap-3"> - <Nav.Link - onClick={() => setTheme(theme === "dark" ? "light" : "dark")} - className="flex items-center text-lg text-gray-800 dark:text-gray-300" - > - {theme === "dark" ? <SunFill /> : <MoonFill />} - </Nav.Link> - - <Nav.Link - onClick={() => setShowSidebar(true)} - className="flex items-center text-lg text-gray-800 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-300 lg:hidden" // Show only on small screens - > - <FaBars /> {/* Hamburger Button */} - </Nav.Link> - </div> - </div> - </Container> - </Navbar> - - <Sidebar show={showSidebar} onClose={() => setShowSidebar(false)} /> - </> - ); -}; - -export default TopNavbar; diff --git a/frontend/src/components/new_card.tsx b/frontend/src/components/new_card.tsx index 04876b5..d6a7b4f 100644 --- a/frontend/src/components/new_card.tsx +++ b/frontend/src/components/new_card.tsx @@ -3,19 +3,14 @@ import { Link } from "react-router-dom"; const NewCardItem: React.FC = () => { return ( - <div className="relative m-4"> - <div className="relative z-10 w-52 h-72 p-3 bg-blue-200 dark:bg-gray-900 border flex flex-col gap-2 opacity-95"> - <h3 className="text-lg font-bold">New Collection</h3> - <p className="text-sm text-gray-900 dark:text-gray-100"> - Please add new collections - </p> - <div className="mt-3 flex gap-2 justify-center items-center grow"> - <Link to="/collection/new"> - <PlusCircleFill size={50} color="white" /> - </Link> - </div> + <div className="w-52 h-64 p-3 bg-blue-200 dark:bg-gray-1 border flex flex-col gap-2 text-gray-900"> + <h3 className="text-lg font-bold">New Collection</h3> + <p className="text-sm">Please add new collections</p> + <div className="mt-3 flex gap-2 justify-center items-center grow"> + <Link to="/collection/new"> + <PlusCircleFill size={50} color="orange" /> + </Link> </div> - <div className="absolute top-5 left-5 w-52 h-72 bg-slate-300 shadow-sm"></div> </div> ); }; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index cdbd4ac..9d2b3be 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -17,6 +17,7 @@ interface AuthContextType { components["schemas"]["UserInfoResponseItem"] | undefined > >; + is_auth: boolean; setApiKeyId: React.Dispatch<React.SetStateAction<string | null>>; signout: () => void; apiKeyId: string | null; @@ -40,6 +41,7 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { const [auth, setAuth] = useState< components["schemas"]["UserInfoResponseItem"] | undefined >(undefined); + const [is_auth, setIsAuth] = useState<boolean>(true); const [apiKeyId, setApiKeyId] = useState<string | null>( getLocalStorageAuth(), ); @@ -47,6 +49,7 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { localStorage.removeItem("token"); setAuth(undefined); setApiKeyId(""); + setIsAuth(false); }; const client = useMemo( () => @@ -74,8 +77,9 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { const fetch_data = async () => { const { data, error } = await client.GET("/me"); if (error) { - console.error("Failed to fetch current user", error); + signout(); } else { + setIsAuth(true); setAuth(data); setApiKeyId(data.token); } @@ -101,6 +105,7 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { <AuthContext.Provider value={{ auth, + is_auth, setAuth, signout, client, diff --git a/frontend/src/contexts/api.tsx b/frontend/src/contexts/api.tsx index 5385fcf..1297056 100644 --- a/frontend/src/contexts/api.tsx +++ b/frontend/src/contexts/api.tsx @@ -7,22 +7,4 @@ export default class api { constructor(client: Client<paths>) { this.client = client; } - - public async upload(files: File[], listing_id: string) { - return await this.client.POST("/artifacts/upload/{listing_id}", { - body: { - files: [], - }, - params: { - path: { - listing_id, - }, - }, - bodySerializer() { - const fd = new FormData(); - files.forEach((file) => fd.append("files", file)); - return fd; - }, - }); - } } diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 4d505c4..750a8c7 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -4,125 +4,6 @@ */ import { Collection, Image } from "types/model"; export interface paths { - "/": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Read Root */ - get: operations["read_root__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/artifacts/url/{artifact_type}/{listing_id}/{name}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Artifact Url */ - get: operations["artifact_url_artifacts_url__artifact_type___listing_id___name__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/artifacts/info/{artifact_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Artifact Info */ - get: operations["get_artifact_info_artifacts_info__artifact_id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/artifacts/list/{listing_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List Artifacts */ - get: operations["list_artifacts_artifacts_list__listing_id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/artifacts/upload/{listing_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Upload */ - post: operations["upload_artifacts_upload__listing_id__post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/artifacts/edit/{artifact_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - /** Edit Artifact */ - put: operations["edit_artifact_artifacts_edit__artifact_id__put"]; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/artifacts/delete/{artifact_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Delete Artifact */ - delete: operations["delete_artifact_artifacts_delete__artifact_id__delete"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/translate": { parameters: { query?: never; @@ -132,341 +13,49 @@ export interface paths { }; get?: never; put?: never; - post: operations["translate"]; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/get_images": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_images"]; - put?: never; - post?: never; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/delete_image": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["delete_image"]; - put?: never; - post?: never; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/create_collection": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["create_collection"]; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/edit_collection": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["edit_collection"]; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/get_collection": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_collection"]; - put?: never; - post?: never; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/get_collections": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_collections"]; - put?: never; - post?: never; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/delete_collection": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["delete_collections"]; - put?: never; - post?: never; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/create_subscription": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["create_subscription"]; - /** Delete Artifact */ - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/email/signup/create": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Create Signup Token - * @description Creates a signup token and emails it to the user. - */ - post: operations["create_signup_token_email_signup_create_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/email/signup/get/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Signup Token */ - get: operations["get_signup_token_email_signup_get__id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/email/signup/delete/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Delete Signup Token */ - delete: operations["delete_signup_token_email_signup_delete__id__delete"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/keys/new": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** New Key */ - post: operations["new_key_keys_new_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/keys/list": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List Keys */ - get: operations["list_keys_keys_list_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/keys/delete/{key}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Delete Key */ - delete: operations["delete_key_keys_delete__key__delete"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/listings/search": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List Listings */ - get: operations["list_listings_listings_search_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/listings/batch": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Batch Listing Info */ - get: operations["get_batch_listing_info_listings_batch_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/listings/dump": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Dump Listings */ - get: operations["dump_listings_listings_dump_get"]; - put?: never; - post?: never; + post: operations["translate"]; + /** Delete Artifact */ delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/listings/user/{id}": { + "/get_images": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List User Listings */ - get: operations["list_user_listings_listings_user__id__get"]; + get: operations["get_images"]; put?: never; post?: never; + /** Delete Artifact */ delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/listings/me": { + "/delete_image": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List My Listings */ - get: operations["list_my_listings_listings_me_get"]; + get: operations["delete_image"]; put?: never; post?: never; + /** Delete Artifact */ delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/listings/add": { + "/create_collection": { parameters: { query?: never; header?: never; @@ -475,15 +64,15 @@ export interface paths { }; get?: never; put?: never; - /** Add Listing */ - post: operations["add_listing_listings_add_post"]; + post: operations["create_collection"]; + /** Delete Artifact */ delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/listings/delete/{listing_id}": { + "/edit_collection": { parameters: { query?: never; header?: never; @@ -492,66 +81,66 @@ export interface paths { }; get?: never; put?: never; - post?: never; - /** Delete Listing */ - delete: operations["delete_listing_listings_delete__listing_id__delete"]; + post: operations["edit_collection"]; + /** Delete Artifact */ + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/listings/edit/{id}": { + "/get_collection": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Edit Listing */ - put: operations["edit_listing_listings_edit__id__put"]; + get: operations["get_collection"]; + put?: never; post?: never; + /** Delete Artifact */ delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/listings/{id}": { + "/get_collections": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Listing */ - get: operations["get_listing_listings__id__get"]; + get: operations["get_collections"]; put?: never; post?: never; + /** Delete Artifact */ delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/listings/{id}/view": { + "/delete_collection": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["delete_collections"]; put?: never; - /** Increment View Count */ - post: operations["increment_view_count_listings__id__view_post"]; + post?: never; + /** Delete Artifact */ delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/listings/{id}/vote": { + "/publish_collection": { parameters: { query?: never; header?: never; @@ -560,43 +149,40 @@ export interface paths { }; get?: never; put?: never; - /** Vote Listing */ - post: operations["vote_listing_listings__id__vote_post"]; - /** Remove Vote */ - delete: operations["remove_vote_listings__id__vote_delete"]; + post: operations["publish_collection"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/onshape/set/{listing_id}": { + "/public_collections": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["public_collections"]; put?: never; - /** Set Onshape Document */ - post: operations["set_onshape_document_onshape_set__listing_id__post"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/onshape/pull/{listing_id}": { + "/create_subscription": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Pull Onshape Document */ - get: operations["pull_onshape_document_onshape_pull__listing_id__get"]; + get?: never; put?: never; - post?: never; + post: operations["create_subscription"]; + /** Delete Artifact */ delete?: never; options?: never; head?: never; @@ -673,204 +259,6 @@ export interface paths { patch?: never; trace?: never; }; - "/users/batch": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Users Batch Endpoint */ - get: operations["get_users_batch_endpoint_users_batch_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/public/batch": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Users Public Batch Endpoint */ - get: operations["get_users_public_batch_endpoint_users_public_batch_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get User Info By Id Endpoint */ - get: operations["get_user_info_by_id_endpoint_users__id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/public/me": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get My Public User Info Endpoint */ - get: operations["get_my_public_user_info_endpoint_users_public_me_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/public/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Public User Info By Id Endpoint */ - get: operations["get_public_user_info_by_id_endpoint_users_public__id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/validate-api-key": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Validate Api Key Endpoint */ - get: operations["validate_api_key_endpoint_users_validate_api_key_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/github/client-id": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Github Client Id Endpoint */ - get: operations["github_client_id_endpoint_users_github_client_id_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/github/code": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Github Code - * @description Gives the user a session token upon successful github authentication and creation of user. - * - * Args: - * data: The request body, containing the code from the OAuth redirect. - * crud: The CRUD object. - * response: The response object. - * - * Returns: - * UserInfoResponse. - */ - post: operations["github_code_users_github_code_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/google/client-id": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Google Client Id Endpoint */ - get: operations["google_client_id_endpoint_users_google_client_id_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/google/login": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Google Login Endpoint */ - post: operations["google_login_endpoint_users_google_login_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/set-moderator": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Set Moderator */ - post: operations["set_moderator_users_set_moderator_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record<string, never>; export interface components { @@ -983,6 +371,10 @@ export interface components { title: string; description: string; }; + CollectionPublishRequest: { + id: string; + flag: boolean; + }; SubscriptionRequest: { payment_method_id: string; email: string; @@ -1742,6 +1134,68 @@ export interface operations { }; }; }; + publish_collection: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CollectionPublishRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": never; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + public_collections: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Array<Collection>; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; translate: { parameters: { query?: never; diff --git a/frontend/src/hooks/alerts.tsx b/frontend/src/hooks/alerts.tsx index 84e8ec1..596d7b8 100644 --- a/frontend/src/hooks/alerts.tsx +++ b/frontend/src/hooks/alerts.tsx @@ -5,7 +5,6 @@ import { useContext, useState, } from "react"; -import { Toast, ToastContainer } from "react-bootstrap"; const DELAY = 5000; const MAX_ERRORS = 10; @@ -15,13 +14,15 @@ type AlertType = "error" | "success" | "primary" | "info"; const alertTypeToBg = (kind: AlertType) => { switch (kind) { case "error": - return "danger"; + return "bg-red-500 text-white"; // Tailwind-style classes case "success": - return "success"; + return "bg-green-500 text-white"; case "primary": - return "primary"; + return "bg-blue-500 text-white"; case "info": - return "secondary"; + return "bg-gray-500 text-white"; + default: + return "bg-gray-500 text-white"; } }; @@ -52,9 +53,9 @@ export const AlertQueueProvider = (props: AlertQueueProviderProps) => { const addAlert = useCallback( (alert: string | ReactNode, kind: AlertType) => { + const alertId = generateAlertId(); setAlerts((prev) => { const newAlerts = new Map(prev); - const alertId = generateAlertId(); newAlerts.set(alertId, [alert, kind]); // Ensure the map doesn't exceed MAX_ERRORS @@ -65,6 +66,10 @@ export const AlertQueueProvider = (props: AlertQueueProviderProps) => { return newAlerts; }); + // Automatically remove the alert after DELAY + setTimeout(() => { + removeAlert(alertId); + }, DELAY); }, [generateAlertId], ); @@ -93,7 +98,7 @@ export const AlertQueueProvider = (props: AlertQueueProviderProps) => { export const useAlertQueue = () => { const context = useContext(AlertQueueContext); if (context === undefined) { - throw new Error("useAlertQueue must be used within a ErrorQueueProvider"); + throw new Error("useAlertQueue must be used within an AlertQueueProvider"); } return context; }; @@ -109,31 +114,45 @@ export const AlertQueue = (props: AlertQueueProps) => { return ( <> {children} - <ToastContainer - className="p-3 mb-8" - position="bottom-center" - style={{ zIndex: 1000, position: "fixed" }} + <div + className="fixed bottom-0 left-0 right-0 flex flex-col items-center space-y-2 p-4" + style={{ zIndex: 1000 }} > - {Array.from(alerts).map(([alertId, [alert, kind]]) => { - return ( - <Toast - key={alertId} - bg={alertTypeToBg(kind)} - autohide - delay={DELAY} - onClose={() => removeAlert(alertId)} - animation={true} - > - <Toast.Header> - <strong className="me-auto"> - {kind.charAt(0).toUpperCase() + kind.slice(1)} - </strong> - </Toast.Header> - <Toast.Body>{alert}</Toast.Body> - </Toast> - ); - })} - </ToastContainer> + {Array.from(alerts).map(([alertId, [alert, kind]]) => ( + <div + key={alertId} + className={`w-full max-w-xs p-4 rounded shadow-lg ${alertTypeToBg( + kind, + )}`} + style={{ animation: "fadeIn 0.5s ease-in-out" }} + > + <div className="flex justify-between items-center"> + <strong className="capitalize">{kind}</strong> + <div + onClick={() => removeAlert(alertId)} + className="ml-2 text-white hover:text-gray-200 cursor-pointer" + > + × + </div> + </div> + <div className="mt-2">{alert}</div> + </div> + ))} + </div> + + {/* Remove the jsx attribute */} + <style>{` + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + `}</style> </> ); }; diff --git a/frontend/src/hooks/auth.tsx b/frontend/src/hooks/auth.tsx deleted file mode 100644 index e07ea68..0000000 --- a/frontend/src/hooks/auth.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import axios, { AxiosInstance } from "axios"; -import { BACKEND_URL } from "constants/backend"; -import { - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useState, -} from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; - -const API_KEY_ID = "__API_KEY"; - -const getLocalStorageApiKey = (): string | null => { - return localStorage.getItem(API_KEY_ID); -}; - -const setLocalStorageApiKey = (token: string) => { - localStorage.setItem(API_KEY_ID, token); -}; - -const deleteLocalStorageApiKey = () => { - localStorage.removeItem(API_KEY_ID); -}; - -interface AuthenticationContextProps { - apiKey: string | null; - setApiKey: (token: string) => void; - logout: () => void; - isAuthenticated: boolean; - api: AxiosInstance; -} - -const AuthenticationContext = createContext< - AuthenticationContextProps | undefined ->(undefined); - -interface AuthenticationProviderProps { - children: ReactNode; -} - -export const AuthenticationProvider = (props: AuthenticationProviderProps) => { - const { children } = props; - - const [apiKey, setApiKey] = useState<string | null>(getLocalStorageApiKey()); - - const navigate = useNavigate(); - - const isAuthenticated = apiKey !== null; - - const api = axios.create({ - baseURL: BACKEND_URL, - withCredentials: true, - }); - - if (apiKey !== null) { - // Adds the API key to the request header since it is set. - api.interceptors.request.use( - (config) => { - config.headers.Authorization = `Bearer ${apiKey}`; - return config; - }, - (error) => { - return Promise.reject(error); - }, - ); - } - - useEffect(() => { - if (apiKey === null) { - deleteLocalStorageApiKey(); - } else { - setLocalStorageApiKey(apiKey); - } - }, [apiKey]); - - const logout = useCallback(() => { - (async () => { - await api.delete<boolean>("/users/logout"); - setApiKey(null); - navigate("/"); - })(); - }, [navigate]); - - return ( - <AuthenticationContext.Provider - value={{ - apiKey, - setApiKey, - logout, - isAuthenticated, - api, - }} - > - {children} - </AuthenticationContext.Provider> - ); -}; - -export const useAuthentication = (): AuthenticationContextProps => { - const context = useContext(AuthenticationContext); - if (!context) { - throw new Error( - "useAuthentication must be used within a AuthenticationProvider", - ); - } - return context; -}; - -interface OneTimePasswordWrapperProps { - children: ReactNode; -} - -interface UserLoginResponse { - api_key: string; -} - -export const OneTimePasswordWrapper = ({ - children, -}: OneTimePasswordWrapperProps) => { - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const { setApiKey, api } = useAuthentication(); - - useEffect(() => { - (async () => { - const payload = searchParams.get("otp"); - if (payload !== null) { - try { - const response = await api.post<UserLoginResponse>("/users/otp", { - payload, - }); - setApiKey(response.data.api_key); - navigate("/"); - } finally { - searchParams.delete("otp"); - } - } - })(); - }, []); - - return <>{children}</>; -}; diff --git a/frontend/src/hooks/theme.tsx b/frontend/src/hooks/theme.tsx index d62eb6b..ad19870 100644 --- a/frontend/src/hooks/theme.tsx +++ b/frontend/src/hooks/theme.tsx @@ -21,7 +21,7 @@ const COLORS: { [key in Theme]: ThemeColors } = { color: "#201a42", }, dark: { - backgroundColor: "#000000", + backgroundColor: "#ffffff", color: "#f5f2ef", }, }; diff --git a/frontend/src/images/KScaleASCII.png b/frontend/src/images/KScaleASCII.png new file mode 100644 index 0000000..1deb260 Binary files /dev/null and b/frontend/src/images/KScaleASCII.png differ diff --git a/frontend/src/images/KScaleASCIIMobile.png b/frontend/src/images/KScaleASCIIMobile.png new file mode 100644 index 0000000..d8e832c Binary files /dev/null and b/frontend/src/images/KScaleASCIIMobile.png differ diff --git a/frontend/src/images/bookplaceholder.png b/frontend/src/images/bookplaceholder.png new file mode 100644 index 0000000..ebe352d Binary files /dev/null and b/frontend/src/images/bookplaceholder.png differ diff --git a/frontend/src/images/small-logo.png b/frontend/src/images/small-logo.png new file mode 100644 index 0000000..487ff9a Binary files /dev/null and b/frontend/src/images/small-logo.png differ diff --git a/frontend/src/pages/Collection.tsx b/frontend/src/pages/Collection.tsx index a7ca922..6b92adb 100644 --- a/frontend/src/pages/Collection.tsx +++ b/frontend/src/pages/Collection.tsx @@ -1,104 +1,30 @@ -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 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 { ListManager } from "react-beautiful-dnd-grid"; -import { Col } from "react-bootstrap"; -import { - ArrowLeft, - CaretLeft, - CaretRight, - SkipBackward, - SkipForward, -} from "react-bootstrap-icons"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; -import { Collection, Image } from "types/model"; +import { useLocation, useParams } from "react-router-dom"; +import { Collection } from "types/model"; const CollectionPage: React.FC = () => { const { id } = useParams<{ id?: string }>(); const location = useLocation(); - const navigate = useNavigate(); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [currentImageIndex, setCurrentImageIndex] = useState(0); - const [currentTranscriptionIndex, setCurrentTranscriptionIndex] = useState(0); - const [currentImage, setCurrentImage] = useState<Image | null>(null); const [collection, setCollection] = useState<Collection | undefined>( undefined, ); const { auth, client } = useAuth(); - const { startLoading, stopLoading } = useLoading(); - const [showUploadModal, setShowUploadModal] = useState(false); - const [showDeleteImageModal, setShowDeleteImageModal] = useState(false); - const [images, setImages] = useState<Array<Image> | undefined>([]); - const [reorderImageIds, setReorderImageIds] = useState<Array<string> | null>( - [], - ); const { addAlert } = useAlertQueue(); - const [deleteImageId, setDeleteImageId] = useState<string>(""); - 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 (collection && collection.images) { - setReorderImageIds([...collection.images]); - } - }, [collection]); - const apiClient1: AxiosInstance = useMemo( - () => - axios.create({ - baseURL: process.env.REACT_APP_BACKEND_URL, - timeout: 1000000, - headers: { - "Content-Type": "multipart/form-data", - Authorization: `Bearer ${auth?.token}`, - }, - }), - [auth?.token], - ); - const API = useMemo(() => new Api(apiClient), [apiClient]); - const API_Uploader = useMemo(() => new Api(apiClient1), [apiClient1]); + // Helper to check if it's an edit action const isEditAction = useMemo( () => location.search.includes("Action=edit"), [location.search], ); - // Get translated images - const translatedImages = useMemo(() => { - // Get translated images - 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]); - // Simulate fetching data for the edit page (mocking API call) useEffect(() => { if (id && auth?.is_auth) { - startLoading(); const asyncfunction = async () => { const { data: collection, error } = await client.GET( "/get_collection", @@ -106,436 +32,67 @@ const CollectionPage: React.FC = () => { ); if (error) addAlert(error.detail?.toString(), "error"); else setCollection(collection); - stopLoading(); }; asyncfunction(); } }, [id, auth]); - useEffect(() => { - if (translatedImages.length > 0) { - setCurrentImage(translatedImages[currentImageIndex]); - } - }, [currentImageIndex, translatedImages]); - - useEffect(() => { - if (collection) { - const asyncfunction = async () => { - startLoading(); - const { data: images, error } = await client.GET("/get_images", { - params: { query: { collection_id: collection.id } }, - }); - if (error) addAlert(error.detail?.toString(), "error"); - else setImages(images); - stopLoading(); - }; - asyncfunction(); - } - }, [collection?.id]); - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - startLoading(); - const { data: collection, error } = await client.POST( - "/create_collection", - { body: { title, description } }, - ); - if (error) addAlert(error.detail?.toString(), "error"); - else 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 - const handleNext = () => { - if (currentImageIndex < translatedImages.length - 1) { - setCurrentImageIndex(currentImageIndex + 1); - setCurrentTranscriptionIndex(0); - } - }; - - const handlePrev = () => { - if (currentImageIndex > 0) { - setCurrentImageIndex(currentImageIndex - 1); - setCurrentTranscriptionIndex(0); - } - }; - // Navigate transcriptions - const handleTranscriptionNext = () => { - if ( - currentImage?.transcriptions && - currentTranscriptionIndex < currentImage?.transcriptions.length - 1 - ) { - setCurrentTranscriptionIndex(currentTranscriptionIndex + 1); - } - }; - - const handleTranscriptionPrev = () => { - if (currentTranscriptionIndex > 0) { - setCurrentTranscriptionIndex(currentTranscriptionIndex - 1); - } - }; // Return button handler - const handleReturn = () => { - navigate("/collections"); - }; - - const handleSave = (e: React.FormEvent) => { - e.preventDefault(); - if (collection && reorderImageIds) { - const asyncfunction = async () => { - startLoading(); - collection.images = reorderImageIds; - const { error } = await client.POST("/edit_collection", { - body: collection, - }); - if (error) addAlert(error.detail?.toString(), "error"); - else { - setCollection({ ...collection }); - addAlert("The collection has been updated successfully!", "success"); - } - stopLoading(); - }; - 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<Image> | undefined = images; - new_images?.push(Image); - if (new_images != undefined) { - setImages(new_images); - collection.images.push(Image.id); - setCollection({ ...collection }); - } - } - } - }; - 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(); - } - }; - // Inside your CollectionPage component - /* eslint-disable */ - const handleDragEnd = (sourceIndex: number, destinationIndex: number) => { - /* eslint-enable */ - if (!reorderImageIds) return; - const [removed] = reorderImageIds.splice(sourceIndex, 1); - reorderImageIds.splice(destinationIndex, 0, removed); - setReorderImageIds([...reorderImageIds]); - // Optionally, you can save the new order to your backend here - }; - - const onShowDeleteImageModal = (id: string) => { - setDeleteImageId(id); - setShowDeleteImageModal(true); - }; - const onDeleteImage = async () => { - if (deleteImageId) { - startLoading(); - const { error } = await client.GET("/delete_image", { - params: { query: { id: deleteImageId } }, - }); - if (error) addAlert(error.detail?.toString(), "error"); - else if (images) { - const filter = images.filter((image) => image.id !== deleteImageId); - setImages(filter); - const filteredId = collection?.images.filter( - (image) => image !== deleteImageId, - ); - if (filteredId) setReorderImageIds(filteredId); - else setReorderImageIds([]); - addAlert("The image has been deleted!", "success"); - } - setShowDeleteImageModal(false); - stopLoading(); - } - }; + // const handleReturn = () => { + // navigate("/collections"); + // }; + + // Navigate View Page + // const handlePreview = () => { + // navigate("/collection/" + collection?.id); + // }; // Custom Return Button (fixed top-left with border) - const ReturnButton = () => ( - <button - className="fixed top-16 left-4 p-2 border-2 border-gray-400 rounded-sm dark:bg-gray-800 shadow-sm" - onClick={handleReturn} - > - <ArrowLeft size={24} /> - </button> - ); + // const ReturnButton = () => ( + // <div className="fixed left-0 mx-4 sm:mx-6 md:mx-10 xl:mx-16"> + // <div className="w-full p-2"> + // <button + // className="p-2 border-2 bg-gray-12 rounded-md shadow-sm w-full" + // onClick={handleReturn} + // > + // <ArrowLeft size={24} /> + // </button> + // </div> + // </div> + // ); + // Custom Return Button (fixed top-left with border) + // const PreviewButton = () => ( + // <div className="fixed right-0 mx-4 sm:mx-6 md:mx-10 xl:mx-16"> + // <div className="w-full p-2"> + // <button + // className="p-2 border-2 bg-gray-12 rounded-md shadow-sm w-full" + // onClick={handlePreview} + // > + // <Eye size={24} /> + // </button> + // </div> + // </div> + // ); // Rendering New Collection Page if (!id) { - return ( - <div className="flex flex-col items-center pt-20 gap-8"> - <h1>New Collection</h1> - <form - className="flex flex-col items-end gap-4 w-full" - onSubmit={handleCreate} - > - <input - className="border p-2 w-full" - type="text" - placeholder="Title" - value={title} - onChange={(e) => setTitle(e.target.value)} - required - /> - <textarea - className="border p-2 w-full h-32" - placeholder="Description" - value={description} - onChange={(e) => setDescription(e.target.value)} - required - /> - <button - className="bg-blue-500 w-32 text-white p-2 rounded hover:bg-blue-600" - type="submit" - > - Create - </button> - </form> - <ReturnButton /> - </div> - ); + return <CollectionNew />; } - // Rendering Edit Collection Page if (id && isEditAction && collection) { return ( - <div className="flex flex-col items-center pt-20 gap-4"> - <h1>Edit Collection </h1> - <form - className="flex flex-col items-center gap-4 w-full" - onSubmit={handleSave} - > - <div className="mt-4 w-full"> - <input - className="border p-2 w-full" - type="text" - placeholder="Title" - value={collection.title} - onChange={(e) => - setCollection({ ...collection, title: e.target.value }) - } - required - /> - </div> - <div className="mt-4 w-full"> - <textarea - className="border p-2 h-32 w-full" - placeholder="Description" - value={collection.description} - onChange={(e) => - setCollection({ ...collection, description: e.target.value }) - } - required - /> - </div> - <div className="mt-4 flex justify-content-end w-full gap-2"> - <button - className="bg-blue-500 text-white w-30 p-2 rounded hover:bg-blue-600" - type="submit" - > - Save Changes - </button> - </div> - </form> - <div className="flex gap-4"> - <button - className="bg-blue-500 text-white w-35 p-2 rounded hover:bg-blue-600" - onClick={() => setShowUploadModal(true)} - > - Add Images - </button> - <button - className="bg-blue-500 text-white w-35 p-2 rounded hover:bg-blue-600 disabled:bg-gray-600" - onClick={(e) => handleSave(e)} - disabled={reorderImageIds?.join() === collection.images.join()} - > - Order Save - </button> - </div> - {/* Upload Modal */} - <Modal - isOpen={showUploadModal} - onClose={() => setShowUploadModal(false)} - > - <UploadContent onUpload={handleUpload} /> - <div className="mt-5 flex justify-end space-x-2"> - <button - className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400" - onClick={() => setShowUploadModal(false)} - > - Close - </button> - </div> - </Modal> - <Modal - isOpen={showDeleteImageModal} - onClose={() => setShowDeleteImageModal(false)} - > - <div className="mt-5 flex justify-end space-x-2 gap-4 items-center"> - <span>Are you sure you want to delete the collection?</span> - <button - className="px-4 py-2 bg-red-700 text-gray-300 rounded hover:bg-red-800" - onClick={onDeleteImage} - > - Delete - </button> - <button - className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400" - onClick={() => setShowDeleteImageModal(false)} - > - Cancel - </button> - </div> - </Modal> - - {reorderImageIds && ( - <ListManager - items={reorderImageIds} - direction="horizontal" - maxItems={3} - onDragEnd={handleDragEnd} - render={(id) => { - const image = images?.find((item) => item.id === id); - return ( - <Col lg={4} md={6} sm={12} className="p-0"> - {image ? ( - <ImageComponent - {...image} - handleTranslateOneImage={handleTranslateOneImage} - showDeleteModal={onShowDeleteImageModal} - /> - ) : ( - <ImageComponent - {...{ - id, - is_translated: false, - image_url: "", - transcriptions: [], - collection: collection.id, - }} - handleTranslateOneImage={handleTranslateOneImage} - showDeleteModal={onShowDeleteImageModal} - /> - )} - </Col> - ); - }} - /> - )} - <ReturnButton key={id} /> - </div> + <CollectionEdit collection={collection} setCollection={setCollection} /> ); } - // Rendering Collection Detail Page if (id && !isEditAction && collection) { - return ( - <div className="flex flex-col items-center pt-20 gap-4"> - <h1 className="text-xl font-bold mb-4">{collection.title}</h1> - <p className="text-md mb-4">{collection.description}</p> - {currentImage ? ( - <div className="flex flex-col align-items-center"> - <img - src={currentImage.image_url} - alt="Collection Image" - className="max-h-96 h-auto mx-auto mb-4" - /> - <p className="mt-2"> - {currentImage.transcriptions[currentTranscriptionIndex].text} - </p> - <p className="mt-2"> - {currentImage.transcriptions[currentTranscriptionIndex].pinyin} - </p> - <p className="mt-2"> - { - currentImage.transcriptions[currentTranscriptionIndex] - .translation - } - </p> - <AudioPlayer - currentImage={currentImage} - index={currentTranscriptionIndex} - /> - - {/* Navigation Buttons */} - <div className="flex justify-content-center mt-4 w-40 gap-4"> - <button - className={`px-5 py-3 rounded ${ - currentImageIndex === 0 - ? "bg-gray-300 text-gray-600 cursor-not-allowed" - : "bg-gray-500 text-white hover:bg-gray-600" - }`} - onClick={handlePrev} - disabled={currentImageIndex === 0} - > - <SkipBackward size={22} /> - </button> - <button - className={`px-5 py-3 rounded ${ - currentTranscriptionIndex === 0 - ? "bg-gray-300 text-gray-600 cursor-not-allowed" - : "bg-gray-500 text-white hover:bg-gray-600" - }`} - onClick={handleTranscriptionPrev} - disabled={currentTranscriptionIndex === 0} - > - <CaretLeft size={22} /> - </button> - <button - className={`px-5 py-3 rounded ${ - currentTranscriptionIndex === - currentImage.transcriptions.length - 1 || - currentImage.transcriptions.length === 0 - ? "bg-gray-300 text-gray-600 cursor-not-allowed" - : "bg-gray-500 text-white hover:bg-gray-600" - }`} - onClick={handleTranscriptionNext} - disabled={ - currentTranscriptionIndex === - currentImage.transcriptions.length - 1 || - currentImage.transcriptions.length === 0 - } - > - <CaretRight size={22} /> - </button> - <button - className={`px-5 py-3 rounded ${ - currentImageIndex === translatedImages.length - 1 - ? "bg-gray-300 text-gray-600 cursor-not-allowed" - : "bg-gray-500 text-white hover:bg-gray-600" - }`} - onClick={handleNext} - disabled={currentImageIndex === translatedImages.length - 1} - > - <SkipForward size={22} /> - </button> - </div> - </div> - ) : ( - <div>No translated images available.</div> - )} - <ReturnButton /> - </div> - ); + return <CollectionView collection={collection} />; } - return <></>; + //skeleton + return ( + <div className="flex flex-col h-full w-full gap-4"> + <div className="bg-gray-3 w-full rounded-lg h-3/4 animate-pulse" /> + <div className="bg-gray-3 w-full rounded-lg h-1/4 animate-pulse" /> + </div> + ); }; - export default CollectionPage; diff --git a/frontend/src/pages/Collections.tsx b/frontend/src/pages/Collections.tsx index 6570e2c..99acabd 100644 --- a/frontend/src/pages/Collections.tsx +++ b/frontend/src/pages/Collections.tsx @@ -1,11 +1,11 @@ -import CardItem from "components/card"; +import Book from "components/Book"; +import BookSkeleton from "components/BookSkeleton"; import Modal from "components/modal"; import NewCardItem from "components/new_card"; import { useAuth } from "contexts/AuthContext"; import { useLoading } from "contexts/LoadingContext"; import { useAlertQueue } from "hooks/alerts"; import { useEffect, useState } from "react"; -import { Col, Row } from "react-bootstrap"; import { Collection } from "types/model"; const Collections = () => { @@ -15,6 +15,7 @@ const Collections = () => { const { startLoading, stopLoading } = useLoading(); const [delete_ID, setDeleteID] = useState(String); const { addAlert } = useAlertQueue(); + const [is_loading, setIsLoading] = useState<boolean>(true); const onDeleteModalShow = (id: string) => { setDeleteID(id); setShowModal(true); @@ -41,32 +42,51 @@ const Collections = () => { useEffect(() => { if (auth?.is_auth) { const asyncfunction = async () => { - startLoading(); const { data: collections, error } = await client.GET("/get_collections"); if (error) addAlert(error.detail?.toString(), "error"); else setCollection(collections); - stopLoading(); + setIsLoading(false); }; asyncfunction(); } }, [auth]); return ( - <div className="flex-column pt-20 gap-4 d-flex justify-content-center"> - <h1>My Collections</h1> - <Row className="align-items-center"> - <Col lg={3} md={4} sm={12}> - <NewCardItem /> - </Col> - {collections?.map((collection) => { - return ( - <Col lg={3} md={4} sm={12} key={collection.id}> - <CardItem {...collection} onDelete={onDeleteModalShow} /> - </Col> - ); - })} - </Row> + <div className="flex-column rounded-md min-h-full items-center bg-gray-3 p-3"> + {auth?.is_auth ? ( + <div className="flex flex-col rounded-md items-start p-24 gap-8"> + <h1 className="text-3xl text-gray-900">My Collections</h1> + <div className="w-full flex flex-wrap gap-8"> + <div className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 xl:w-1/5 2xl:w-1/6"> + <NewCardItem /> + </div> + {is_loading ? ( + <BookSkeleton is_light={true} /> + ) : ( + collections?.map((collection) => { + return ( + <div + key={collection.id} + className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 xl:w-1/5 2xl:w-1/6" + > + <Book + title={collection.title} + description={collection.title} + id={collection.id} + featured_image={collection.featured_image} + is_editable={true} + onDelete={onDeleteModalShow} + /> + </div> + ); + }) + )} + </div> + </div> + ) : ( + <></> + )} {/* Delete Modal */} <Modal isOpen={showModal} onClose={() => setShowModal(false)}> <div className="mt-5 flex justify-end space-x-2 gap-4 items-center"> diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 1c83be4..b6ea186 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,45 +1,103 @@ -import avatar from "assets/avatar.png"; -import { Col, Container, Row } from "react-bootstrap"; +import Book from "components/Book"; +import BookSkeleton from "components/BookSkeleton"; +import { useAuth } from "contexts/AuthContext"; +import { useEffect, useState } from "react"; +import { Collection } from "types/model"; const Home = () => { + const [public_collections, setPublicCollections] = useState< + Array<Collection> | undefined + >([]); + const [collections, setCollections] = useState<Array<Collection> | undefined>( + [], + ); + const { auth, client } = useAuth(); + const [is_collection_roading, setIsCollectionRoading] = + useState<boolean>(true); + const [is_public_collection_roading, setIsPublicCollectionRoading] = + useState<boolean>(true); + useEffect(() => { + (async () => { + const { data } = await client.GET("/public_collections"); + setPublicCollections(data); + setIsPublicCollectionRoading(false); + })(); + }, [client]); + useEffect(() => { + if (auth?.is_auth) + (async () => { + const { data } = await client.GET("/get_collections"); + setCollections(data); + setIsCollectionRoading(false); + })(); + }, [client, auth]); return ( - <Container - fluid - className="d-flex flex-column justify-content-center align-items-center min-vh-100" - > - <Row className="align-items-center w-100"> + <div className="flex flex-col gap-2 h-full"> + <div className="flex flex-wrap rounded-md items-center bg-gray-12 px-4"> {/* Text Section */} - <Col - lg={4} - md={8} - sm={12} - className="text-center text-md-start d-flex flex-column justify-content-center" - > - <h1 className="display-4">LinguaPhoto</h1> - <p className="lead">Visual language learning for everyone!</p> - <Row className="mt-3">{/* GoogleAuthComponent placeholder */}</Row> - </Col> - - {/* Image Section */} - <Col - lg={8} - md={8} - sm={12} - className="d-flex justify-content-center align-items-center" - > - <img - src={avatar} - alt="Avatar" - className="img-fluid" - style={{ - maxHeight: "80vh", // Keeps image height responsive - maxWidth: "100%", - objectFit: "contain", // Prevents overflow - }} - /> - </Col> - </Row> - </Container> + <div className="flex flex-col justify-center items-center text-md-start h-72 text-3xl w-full"> + <h1>LinguaPhoto</h1> + <p>Visual language learning for everyone!</p> + <div className="flex felx-wrap mt-3"> + {/* GoogleAuthComponent placeholder */} + </div> + </div> + </div> + <div className="flex flex-col rounded-md items-start bg-gray-3 p-24 gap-8"> + <h1 className="text-3xl text-gray-900">Public Collections</h1> + <div className="w-full flex flex-wrap gap-8"> + {is_public_collection_roading ? ( + // skeleton for public collections + <BookSkeleton is_light={true} /> + ) : ( + public_collections?.map((collection) => { + return ( + <div + key={collection.id} + className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 xl:w-1/5 2xl:w-1/6" + > + <Book + title={collection.title} + description={collection.title} + id={collection.id} + featured_image={collection.featured_image} + /> + </div> + ); + }) + )} + </div> + </div> + {auth?.is_auth ? ( + <div className="flex flex-col rounded-md items-start bg-gray-12 p-24 gap-8"> + <h1 className="text-3xl">My Collections</h1> + <div className="w-full flex flex-wrap gap-8"> + {is_collection_roading ? ( + // skeleton for my collections + <BookSkeleton is_light={false} /> + ) : ( + collections?.map((collection) => { + return ( + <div + key={collection.id} + className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 xl:w-1/5 2xl:w-1/6" + > + <Book + title={collection.title} + description={collection.title} + id={collection.id} + featured_image={collection.featured_image} + /> + </div> + ); + }) + )} + </div> + </div> + ) : ( + <></> + )} + </div> ); }; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index d74f61a..a302bcd 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -10,12 +10,12 @@ const LoginPage: React.FC = () => { const [password, setPassword] = useState(""); const [username, setName] = useState(""); const { startLoading, stopLoading } = useLoading(); - const { auth, setAuth, client, setApiKeyId } = useAuth(); + const { auth, is_auth, setAuth, client, setApiKeyId } = useAuth(); const navigate = useNavigate(); const { addAlert } = useAlertQueue(); useEffect(() => { - if (auth?.is_auth) navigate("/collections"); - }, [auth]); + if (is_auth && auth?.is_auth) navigate("/collections"); + }, [is_auth, auth?.is_auth]); // Toggle between login and signup forms const handleSwitch = () => { setIsSignup(!isSignup); @@ -58,9 +58,9 @@ const LoginPage: React.FC = () => { }; return ( - <div className="flex items-center justify-center h-screen"> - <div className="border rounded-lg shadow-md p-6 w-full max-w-md"> - <h2 className="text-2xl font-bold mb-4 text-center"> + <div className="flex flex-wrap rounded-md h-full items-center justify-center bg-gray-3"> + <div className="border border-gray-600 rounded-lg shadow-md p-6 w-full max-w-md"> + <h2 className="text-2xl font-bold text-gray-700 mb-4 text-center"> {isSignup ? "Sign Up" : "Login"} </h2> <form className="space-y-4" onSubmit={handleSubmit}> @@ -128,7 +128,7 @@ const LoginPage: React.FC = () => { <hr className="flex-grow border-t border-gray-300" /> </div> - <button className="bg-gray-800 text-white p-2 rounded w-full flex items-center justify-center hover:bg-gray-900"> + <button className="bg-gray-11 text-white p-2 rounded w-full hover:bg-gray-10 flex flex-wrap items-center justify-center"> <Google className="mr-2" size={20} /> {isSignup ? "Sign Up with Google" : "Login with Google"} </button> @@ -137,22 +137,22 @@ const LoginPage: React.FC = () => { {isSignup ? ( <> Already have an account?{" "} - <button + <span onClick={handleSwitch} - className="text-blue-500 hover:underline" + className="text-blue-500 hover:underline cursor-pointer" > Login here - </button> + </span> </> ) : ( <> Don't have an account?{" "} - <button + <span onClick={handleSwitch} - className="text-blue-500 hover:underline" + className="text-blue-500 hover:underline cursor-pointer" > Create a new account - </button> + </span> </> )} </p> diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx index 9b39a7c..dc5ec1d 100644 --- a/frontend/src/pages/NotFound.tsx +++ b/frontend/src/pages/NotFound.tsx @@ -1,14 +1,12 @@ -import { Col, Row } from "react-bootstrap"; - const NotFound = () => { return ( <div className="pt-5 rounded-lg flex items-center justify-center h-screen"> - <Row> - <Col> + <div className="flex flex-wrap"> + <div className="w-full"> <h1 className="display-4">404 Not Found</h1> <p className="lead">The page you are looking for does not exist</p> - </Col> - </Row> + </div> + </div> </div> ); }; diff --git a/frontend/src/pages/Subscription.tsx b/frontend/src/pages/Subscription.tsx index 44390b9..b7c0eb3 100644 --- a/frontend/src/pages/Subscription.tsx +++ b/frontend/src/pages/Subscription.tsx @@ -2,22 +2,17 @@ import React from "react"; const SubscriptionCancelPage: React.FC = () => { return ( - <div className="flex flex-column gap-8 justify-center items-center min-h-screen"> + <div className="flex flex-col rounded-md h-full items-center justify-center bg-gray-3 p-24 gap-12 text-gray-900"> <h1 className="text-3xl"> This page is on developing. will be updated soon </h1> - <div className="w-full max-w-md p-6 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg shadow"> + <div className="w-full max-w-md p-6 border border-gray-300 bg-white rounded-lg shadow"> {/* Current Plan Section */} <div className="mb-6"> - <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> - Current Plan - </h2> - <p className="text-gray-700 dark:text-gray-300 mt-2"> + <h2 className="text-gray-700 text-lg font-semibold">Current Plan</h2> + <p className="mt-2"> You have been subscribed to the{" "} - <span className="font-semibold text-green-600 dark:text-green-400"> - Premium Plan - </span> - . + <span className="font-semibold text-green-700">Premium Plan</span>. </p> <button className="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md w-full"> Cancel Subscription @@ -26,12 +21,8 @@ const SubscriptionCancelPage: React.FC = () => { {/* Billing Cycle Section */} <div> - <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> - Current Billing Cycle - </h2> - <p className="text-gray-700 dark:text-gray-300 mt-2"> - 2023.05.05 - 2023.06.05 - </p> + <h2 className="text-lg font-semibold">Current Billing Cycle</h2> + <p className="text-gray-700 mt-2">2023.05.05 - 2023.06.05</p> </div> </div> </div> diff --git a/frontend/src/types/model.ts b/frontend/src/types/model.ts index a648b88..4770409 100644 --- a/frontend/src/types/model.ts +++ b/frontend/src/types/model.ts @@ -3,6 +3,9 @@ export interface Collection { title: string; description: string; images: Array<string>; + user: string; + featured_image: string; + publish_flag: boolean; } interface Transcription { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 70b003e..da17d57 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -3,9 +3,47 @@ export default { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], darkMode: "class", theme: { - extend: {}, - }, - plugins: [], - important: true -} - + extend: { + fontFamily: { + sans: ["Inter", "sans-serif"], + orbitron: ["Orbitron", "sans-serif"], + }, + colors: { + // compliments international orange primary color palette + gray: { + 1: "#fcfcfd", + 2: "#f9f9fb", + 3: "#eff0f3", + 4: "#e7e8ec", + 5: "#e0e1e6", + 6: "#d8d9e0", + 7: "#cdced7", + 8: "#b9bbc6", + 9: "#8b8d98", + 10: "#80828d", + 11: "#62636c", + 12: "#1e1f24", + }, + // Radix UI International Orange Colorscale 1-12 (light) + primary: { + 1: "#fefcfb", + 2: "#fff5f1", + 3: "#ffe8de", + 4: "#ffd7c7", + 5: "#ffc9b4", + 6: "#ffb89f", + 7: "#ffa284", + 8: "#fb8765", + 9: "#ff4f00", + 10: "#f14000", + 11: "#de3500", + 12: "#5d291a", + DEFAULT: "#ff4f00", + foreground: "#5d291a", + }, + }, + }, + plugins: [], + important: true + } +} \ No newline at end of file diff --git a/linguaphoto/api/collection.py b/linguaphoto/api/collection.py index b7bc7f7..a8c0ce8 100644 --- a/linguaphoto/api/collection.py +++ b/linguaphoto/api/collection.py @@ -10,6 +10,7 @@ from linguaphoto.schemas.collection import ( CollectionCreateFragment, CollectionEditFragment, + CollectionPublishFragment, ) from linguaphoto.utils.auth import get_current_user_id @@ -64,7 +65,12 @@ async def editcollection( async with collection_crud: await collection_crud.edit_collection( collection.id, - updates={"title": collection.title, "description": collection.description, "images": collection.images}, + updates={ + "title": collection.title, + "description": collection.description, + "images": collection.images, + "featured_image": collection.featured_image, + }, ) return @@ -78,3 +84,23 @@ async def deletecollection( async with collection_crud: await collection_crud.delete_collection(collection_id=id) return + + +@router.post("/publish_collection") +async def publishcollection( + data: CollectionPublishFragment, + user_id: str = Depends(get_current_user_id), + collection_crud: CollectionCrud = Depends(), +) -> None: + async with collection_crud: + await collection_crud.edit_collection(data.id, updates={"publish_flag": data.flag}) + return + + +@router.get("/public_collections") +async def publiccoleections( + collection_crud: CollectionCrud = Depends(), +) -> List[Collection]: + async with collection_crud: + collections = await collection_crud.get_public_collections() + return collections diff --git a/linguaphoto/crud/base.py b/linguaphoto/crud/base.py index c3cbdf0..676d609 100644 --- a/linguaphoto/crud/base.py +++ b/linguaphoto/crud/base.py @@ -255,7 +255,7 @@ async def _list_items( table = await self.db.Table(TABLE_NAME) query_params = { - "IndexName": "type_index", + "IndexName": "type-index", "KeyConditionExpression": Key("type").eq(item_class.__name__), "Limit": limit, } diff --git a/linguaphoto/crud/collection.py b/linguaphoto/crud/collection.py index 32e11e2..5ce56e7 100644 --- a/linguaphoto/crud/collection.py +++ b/linguaphoto/crud/collection.py @@ -25,3 +25,12 @@ async def edit_collection(self, collection_id: str, updates: dict) -> None: async def delete_collection(self, collection_id: str) -> None: await self._delete_item(collection_id) + + async def get_public_collections(self) -> List[Collection]: + collections = await self._list_items( + item_class=Collection, + filter_expression="#flag=:flag", + expression_attribute_names={"#flag": "publish_flag"}, + expression_attribute_values={":flag": True}, + ) + return collections diff --git a/linguaphoto/models.py b/linguaphoto/models.py index 15baa57..4d2693c 100644 --- a/linguaphoto/models.py +++ b/linguaphoto/models.py @@ -58,6 +58,8 @@ class Collection(LinguaBaseModel): description: str images: List[str] = [] user: str + featured_image: str = "" + publish_flag: bool = False @classmethod def create(cls, title: str, description: str, user_id: str) -> Self: diff --git a/linguaphoto/schemas/collection.py b/linguaphoto/schemas/collection.py index 897b3b3..bb65524 100644 --- a/linguaphoto/schemas/collection.py +++ b/linguaphoto/schemas/collection.py @@ -15,3 +15,9 @@ class CollectionEditFragment(BaseModel): title: Optional[str] description: Optional[str] images: Optional[List[str]] + featured_image: Optional[str] + + +class CollectionPublishFragment(BaseModel): + id: str + flag: bool