From 85f89b4df11f804dcad2b36dc1e0cd73f911588d Mon Sep 17 00:00:00 2001 From: Mamadou DICKO <63923024+mamadoudicko@users.noreply.github.com> Date: Sat, 27 May 2023 00:12:57 +0200 Subject: [PATCH] Feat/improve UI (#174) * feat(signup): add sign in page link * feat(upload): improve ui * ui(header): add logout button * feat(login): add redirection for logged user --- frontend/app/(auth)/login/page.tsx | 15 +- frontend/app/(auth)/signup/page.tsx | 2 + frontend/app/components/NavBar/NavItems.tsx | 10 +- frontend/app/components/ui/Divider.tsx | 27 ++ frontend/app/layout.tsx | 18 +- .../components/Crawler/helpers/isValidUrl.ts | 8 + .../components/Crawler/hooks/useCrawler.ts | 72 +++++ .../app/upload/components/Crawler/index.tsx | 37 +++ .../FileUploader/components/FileComponent.tsx | 44 +++ .../FileUploader/hooks/useFileUploader.ts | 103 +++++++ .../upload/components/FileUploader/index.tsx | 80 +++++ frontend/app/upload/hooks/useToast.ts | 25 ++ frontend/app/upload/page.tsx | 286 ++---------------- 13 files changed, 442 insertions(+), 285 deletions(-) create mode 100644 frontend/app/components/ui/Divider.tsx create mode 100644 frontend/app/upload/components/Crawler/helpers/isValidUrl.ts create mode 100644 frontend/app/upload/components/Crawler/hooks/useCrawler.ts create mode 100644 frontend/app/upload/components/Crawler/index.tsx create mode 100644 frontend/app/upload/components/FileUploader/components/FileComponent.tsx create mode 100644 frontend/app/upload/components/FileUploader/hooks/useFileUploader.ts create mode 100644 frontend/app/upload/components/FileUploader/index.tsx create mode 100644 frontend/app/upload/hooks/useToast.ts diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 23abe838f..9def8b4a3 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -1,16 +1,16 @@ "use client"; -import { useRef, useState } from "react"; -import { useRouter } from "next/navigation"; -import { useSupabase } from "@/app/supabase-provider"; import Button from "@/app/components/ui/Button"; import Card from "@/app/components/ui/Card"; import Field from "@/app/components/ui/Field"; import PageHeading from "@/app/components/ui/PageHeading"; import Toast, { ToastRef } from "@/app/components/ui/Toast"; +import { useSupabase } from "@/app/supabase-provider"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useRef, useState } from "react"; export default function Login() { - const { supabase } = useSupabase(); + const { supabase, session } = useSupabase(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [isPending, setIsPending] = useState(false); @@ -27,22 +27,23 @@ export default function Login() { }); if (error) { - console.error("Error logging in:", error.message); loginToast.current?.publish({ variant: "danger", text: error.message, }); } else if (data) { - console.log("User logged in:", data); loginToast.current?.publish({ variant: "success", text: "Successfully logged in", }); - router.replace("/"); } setIsPending(false); }; + if (session?.user !== undefined) { + router.replace("/"); + } + return (
diff --git a/frontend/app/(auth)/signup/page.tsx b/frontend/app/(auth)/signup/page.tsx index 02e58e543..4917fab1b 100644 --- a/frontend/app/(auth)/signup/page.tsx +++ b/frontend/app/(auth)/signup/page.tsx @@ -5,6 +5,7 @@ import Field from "@/app/components/ui/Field"; import PageHeading from "@/app/components/ui/PageHeading"; import Toast, { ToastRef } from "@/app/components/ui/Toast"; import { useSupabase } from "@/app/supabase-provider"; +import Link from "next/link"; import { useRef, useState } from "react"; export default function SignUp() { @@ -64,6 +65,7 @@ export default function SignUp() { />
+ Already registered? Sign in
diff --git a/frontend/app/components/NavBar/NavItems.tsx b/frontend/app/components/NavBar/NavItems.tsx index 5573d0fb7..0fbc56c82 100644 --- a/frontend/app/components/NavBar/NavItems.tsx +++ b/frontend/app/components/NavBar/NavItems.tsx @@ -1,14 +1,17 @@ +import { useSupabase } from "@/app/supabase-provider"; import { cn } from "@/lib/utils"; import Link from "next/link"; import { Dispatch, FC, HTMLAttributes, ReactNode, SetStateAction } from "react"; -import DarkModeToggle from "./DarkModeToggle"; import Button from "../ui/Button"; +import DarkModeToggle from "./DarkModeToggle"; interface NavItemsProps extends HTMLAttributes { setOpen?: Dispatch>; } const NavItems: FC = ({ className, setOpen, ...props }) => { + const { session } = useSupabase(); + const isUserLoggedIn = session?.user !== undefined; return (
    = ({ className, setOpen, ...props }) => { + {isUserLoggedIn && ( + + + + )}
); diff --git a/frontend/app/components/ui/Divider.tsx b/frontend/app/components/ui/Divider.tsx new file mode 100644 index 000000000..494ce1928 --- /dev/null +++ b/frontend/app/components/ui/Divider.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/utils"; +import { FC, HTMLAttributes, LegacyRef, forwardRef } from "react"; + +type DividerProps = HTMLAttributes & { + text?: string; +}; + +const Divider: FC = forwardRef( + ({ className, text, ...props }, ref) => { + return ( +
} + className={cn("flex items-center justify-center", className)} + {...props} + > +
+ {text !== undefined && ( +

{text}

+ )} +
+
+ ); + } +); +Divider.displayName = "AnimatedCard"; + +export { Divider }; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index c83d2dd6f..d84a40561 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,11 +1,11 @@ -import { Analytics } from "@vercel/analytics/react"; -import NavBar from "./components/NavBar"; -import "./globals.css"; -import { Inter } from "next/font/google"; -import SupabaseProvider from "./supabase-provider"; import { createServerComponentSupabaseClient } from "@supabase/auth-helpers-nextjs"; -import { headers, cookies } from "next/headers"; +import { Analytics } from "@vercel/analytics/react"; +import { Inter } from "next/font/google"; +import { cookies, headers } from "next/headers"; +import NavBar from "./components/NavBar"; import { ToastProvider } from "./components/ui/Toast"; +import "./globals.css"; +import SupabaseProvider from "./supabase-provider"; const inter = Inter({ subsets: ["latin"] }); @@ -34,9 +34,11 @@ export default async function RootLayout({ - - {children} + + + {children} + diff --git a/frontend/app/upload/components/Crawler/helpers/isValidUrl.ts b/frontend/app/upload/components/Crawler/helpers/isValidUrl.ts new file mode 100644 index 000000000..908f9b1c8 --- /dev/null +++ b/frontend/app/upload/components/Crawler/helpers/isValidUrl.ts @@ -0,0 +1,8 @@ +export const isValidUrl = (string: string) => { + try { + new URL(string); + return true; + } catch (_) { + return false; + } +}; diff --git a/frontend/app/upload/components/Crawler/hooks/useCrawler.ts b/frontend/app/upload/components/Crawler/hooks/useCrawler.ts new file mode 100644 index 000000000..d82f74390 --- /dev/null +++ b/frontend/app/upload/components/Crawler/hooks/useCrawler.ts @@ -0,0 +1,72 @@ +import { useSupabase } from "@/app/supabase-provider"; +import axios from "axios"; +import { redirect } from "next/navigation"; +import { useCallback, useRef, useState } from "react"; +import { useToast } from "../../../hooks/useToast"; +import { isValidUrl } from "../helpers/isValidUrl"; + +export const useCrawler = () => { + const [isCrawling, setCrawling] = useState(false); + const urlInputRef = useRef(null); + const { setMessage, messageToast } = useToast(); + const { session } = useSupabase(); + if (session === null) { + redirect("/login"); + } + + const crawlWebsite = useCallback(async () => { + setCrawling(true); + // Validate URL + const url = urlInputRef.current ? urlInputRef.current.value : null; + + if (!url || !isValidUrl(url)) { + // Assuming you have a function to validate URLs + setMessage({ + type: "error", + text: "Invalid URL", + }); + setCrawling(false); + return; + } + + // Configure parameters + const config = { + url: url, + js: false, + depth: 1, + max_pages: 100, + max_time: 60, + }; + + try { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/crawl`, + config, + { + headers: { + Authorization: `Bearer ${session.access_token}`, + }, + } + ); + + setMessage({ + type: response.data.type, + text: response.data.message, + }); + } catch (error: unknown) { + setMessage({ + type: "error", + text: "Failed to crawl website: " + JSON.stringify(error), + }); + } finally { + setCrawling(false); + } + }, [session.access_token]); + + return { + isCrawling, + urlInputRef, + messageToast, + crawlWebsite, + }; +}; diff --git a/frontend/app/upload/components/Crawler/index.tsx b/frontend/app/upload/components/Crawler/index.tsx new file mode 100644 index 000000000..62ae63589 --- /dev/null +++ b/frontend/app/upload/components/Crawler/index.tsx @@ -0,0 +1,37 @@ +"use client"; +import Button from "@/app/components/ui/Button"; +import Card from "@/app/components/ui/Card"; +import Field from "@/app/components/ui/Field"; +import Toast from "@/app/components/ui/Toast"; +import { useCrawler } from "./hooks/useCrawler"; + +export const Crawler = (): JSX.Element => { + const { urlInputRef, isCrawling, messageToast, crawlWebsite } = useCrawler(); + return ( +
+
+
+
+ +
+ +
+
+ +
+
+
+ +
+
+
+ ); +}; diff --git a/frontend/app/upload/components/FileUploader/components/FileComponent.tsx b/frontend/app/upload/components/FileUploader/components/FileComponent.tsx new file mode 100644 index 000000000..2929a3ffd --- /dev/null +++ b/frontend/app/upload/components/FileUploader/components/FileComponent.tsx @@ -0,0 +1,44 @@ +import { motion } from "framer-motion"; +import { Dispatch, SetStateAction } from "react"; +import { MdClose } from "react-icons/md"; + +export const FileComponent = ({ + file, + setFiles, +}: { + file: File; + setFiles: Dispatch>; +}) => { + return ( + +
+
+ + {file.name} + + + {(file.size / 1000).toFixed(3)} kb + +
+
+
+ +
+
+ ); +}; diff --git a/frontend/app/upload/components/FileUploader/hooks/useFileUploader.ts b/frontend/app/upload/components/FileUploader/hooks/useFileUploader.ts new file mode 100644 index 000000000..afb09116c --- /dev/null +++ b/frontend/app/upload/components/FileUploader/hooks/useFileUploader.ts @@ -0,0 +1,103 @@ +import { useSupabase } from "@/app/supabase-provider"; +import { useToast } from "@/app/upload/hooks/useToast"; +import axios from "axios"; +import { redirect } from "next/navigation"; +import { useCallback, useState } from "react"; +import { FileRejection, useDropzone } from "react-dropzone"; + +export const useFileUploader = () => { + const [isPending, setIsPending] = useState(false); + const { messageToast, setMessage } = useToast(); + const [files, setFiles] = useState([]); + const [pendingFileIndex, setPendingFileIndex] = useState(0); + const { session } = useSupabase(); + + if (session === null) { + redirect("/login"); + } + + const upload = useCallback( + async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + try { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/upload`, + formData, + { + headers: { + Authorization: `Bearer ${session.access_token}`, + }, + } + ); + + setMessage({ + type: response.data.type, + text: + (response.data.type === "success" + ? "File uploaded successfully: " + : "") + JSON.stringify(response.data.message), + }); + } catch (error: unknown) { + setMessage({ + type: "error", + text: "Failed to upload file: " + JSON.stringify(error), + }); + } + }, + [session.access_token] + ); + + const onDrop = (acceptedFiles: File[], fileRejections: FileRejection[]) => { + if (fileRejections.length > 0) { + setMessage({ type: "error", text: "File too big." }); + return; + } + setMessage(null); + for (let i = 0; i < acceptedFiles.length; i++) { + const file = acceptedFiles[i]; + const isAlreadyInFiles = + files.filter((f) => f.name === file.name && f.size === file.size) + .length > 0; + if (isAlreadyInFiles) { + setMessage({ + type: "warning", + text: `${file.name} was already added`, + }); + acceptedFiles.splice(i, 1); + } + } + setFiles((files) => [...files, ...acceptedFiles]); + }; + + const uploadAllFiles = async () => { + setIsPending(true); + setMessage(null); + // files.forEach((file) => upload(file)); + for (const file of files) { + await upload(file); + setPendingFileIndex((i) => i + 1); + } + setPendingFileIndex(0); + setIsPending(false); + }; + + const { getInputProps, getRootProps, isDragActive, open } = useDropzone({ + onDrop, + noClick: true, + maxSize: 100000000, // 1 MB + }); + + return { + isPending, + getInputProps, + getRootProps, + isDragActive, + open, + uploadAllFiles, + pendingFileIndex, + messageToast, + files, + setFiles, + }; +}; diff --git a/frontend/app/upload/components/FileUploader/index.tsx b/frontend/app/upload/components/FileUploader/index.tsx new file mode 100644 index 000000000..8ca2b1008 --- /dev/null +++ b/frontend/app/upload/components/FileUploader/index.tsx @@ -0,0 +1,80 @@ +"use client"; +import Toast from "@/app/components/ui/Toast"; +import { AnimatePresence } from "framer-motion"; +import Button from "../../../components/ui/Button"; +import Card from "../../../components/ui/Card"; +import { FileComponent } from "./components/FileComponent"; +import { useFileUploader } from "./hooks/useFileUploader"; + +export const FileUploader = (): JSX.Element => { + const { + getInputProps, + getRootProps, + isDragActive, + isPending, + messageToast, + open, + pendingFileIndex, + uploadAllFiles, + files, + setFiles, + } = useFileUploader(); + + return ( + <> +
+
+
+ {/* Assign a width of 50% to each card */} +
+ + +
+ {isDragActive ? ( +

Drop the files here...

+ ) : ( + + )} +
+
+
+ + {files.length > 0 && ( +
+ + {files.length > 0 ? ( + + {files.map((file) => ( + + ))} + + ) : null} + +
+ )} +
+
+ +
+
+
+ + + ); +}; diff --git a/frontend/app/upload/hooks/useToast.ts b/frontend/app/upload/hooks/useToast.ts new file mode 100644 index 000000000..aca17092d --- /dev/null +++ b/frontend/app/upload/hooks/useToast.ts @@ -0,0 +1,25 @@ +import { ToastRef } from "@/app/components/ui/Toast"; +import { Message } from "postcss"; +import { useEffect, useRef, useState } from "react"; + +export const useToast = () => { + const [message, setMessage] = useState(null); + const messageToast = useRef(null); + + useEffect(() => { + if (!message) return; + messageToast.current?.publish({ + variant: + message.type === "error" + ? "danger" + : message.type === "warning" + ? "neutral" + : "success", + text: message.text, + }); + }, [message]); + return { + setMessage, + messageToast, + }; +}; diff --git a/frontend/app/upload/page.tsx b/frontend/app/upload/page.tsx index 6fe1be077..a0c1bcd17 100644 --- a/frontend/app/upload/page.tsx +++ b/frontend/app/upload/page.tsx @@ -1,280 +1,28 @@ "use client"; -import { Message } from "@/lib/types"; -import axios from "axios"; -import { AnimatePresence, motion } from "framer-motion"; import Link from "next/link"; -import { redirect } from "next/navigation"; -import { - Dispatch, - SetStateAction, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { FileRejection, useDropzone } from "react-dropzone"; -import { MdClose } from "react-icons/md"; import Button from "../components/ui/Button"; -import Card from "../components/ui/Card"; -import Field from "../components/ui/Field"; +import { Divider } from "../components/ui/Divider"; import PageHeading from "../components/ui/PageHeading"; -import Toast, { ToastRef } from "../components/ui/Toast"; -import { useSupabase } from "../supabase-provider"; +import { Crawler } from "./components/Crawler"; +import { FileUploader } from "./components/FileUploader"; export default function UploadPage() { - const [message, setMessage] = useState(null); - const [isPending, setIsPending] = useState(false); - const [files, setFiles] = useState([]); - const [pendingFileIndex, setPendingFileIndex] = useState(0); - const urlInputRef = useRef(null); - const { session } = useSupabase(); - if (session === null) { - redirect("/login"); - } - - const messageToast = useRef(null); - - useEffect(() => { - if (!message) return; - messageToast.current?.publish({ - variant: - message.type === "error" - ? "danger" - : message.type === "warning" - ? "neutral" - : "success", - text: message.text, - }); - }, [message]); - - const crawlWebsite = useCallback(async () => { - // Validate URL - const url = urlInputRef.current ? urlInputRef.current.value : null; - if (!url || !isValidUrl(url)) { - // Assuming you have a function to validate URLs - setMessage({ - type: "error", - text: "Invalid URL", - }); - return; - } - - // Configure parameters - const config = { - url: url, - js: false, - depth: 1, - max_pages: 100, - max_time: 60, - }; - - try { - const response = await axios.post( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/crawl`, - config, - { - headers: { - Authorization: `Bearer ${session.access_token}`, - }, - } - ); - - setMessage({ - type: response.data.type, - text: response.data.message, - }); - } catch (error: unknown) { - setMessage({ - type: "error", - text: "Failed to crawl website: " + JSON.stringify(error), - }); - } - }, [session.access_token]); - - const upload = useCallback( - async (file: File) => { - const formData = new FormData(); - formData.append("file", file); - try { - const response = await axios.post( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/upload`, - formData, - { - headers: { - Authorization: `Bearer ${session.access_token}`, - }, - } - ); - - setMessage({ - type: response.data.type, - text: - (response.data.type === "success" - ? "File uploaded successfully: " - : "") + JSON.stringify(response.data.message), - }); - } catch (error: unknown) { - setMessage({ - type: "error", - text: "Failed to upload file: " + JSON.stringify(error), - }); - } - }, - [session.access_token] - ); - - const onDrop = (acceptedFiles: File[], fileRejections: FileRejection[]) => { - if (fileRejections.length > 0) { - setMessage({ type: "error", text: "File too big." }); - return; - } - setMessage(null); - for (let i = 0; i < acceptedFiles.length; i++) { - const file = acceptedFiles[i]; - const isAlreadyInFiles = - files.filter((f) => f.name === file.name && f.size === file.size) - .length > 0; - if (isAlreadyInFiles) { - setMessage({ type: "warning", text: `${file.name} was already added` }); - acceptedFiles.splice(i, 1); - } - } - setFiles((files) => [...files, ...acceptedFiles]); - }; - - const uploadAllFiles = async () => { - setIsPending(true); - setMessage(null); - // files.forEach((file) => upload(file)); - for (const file of files) { - await upload(file); - setPendingFileIndex((i) => i + 1); - } - setPendingFileIndex(0); - setIsPending(false); - }; - - const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ - onDrop, - noClick: true, - maxSize: 100000000, // 1 MB - }); - return ( -
-
- - {/* Wrap the cards in a flex container */} -
- {/* Assign a width of 50% to each card */} - - -
- {files.length > 0 ? ( - - {files.map((file) => ( - - ))} - - ) : null} - - {isDragActive ? ( -

Drop the files here...

- ) : ( - - )} -
-
- {/* Assign a width of 50% to each card */} - -
- - -
-
-
-
- - - - -
-
- + +
); } - -const FileComponent = ({ - file, - setFiles, -}: { - file: File; - setFiles: Dispatch>; -}) => { - return ( - -
- {file.name} - - {(file.size / 1000).toFixed(3)} kb - -
- -
- ); -}; - -function isValidUrl(string: string) { - try { - new URL(string); - return true; - } catch (_) { - return false; - } -}