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
This commit is contained in:
Mamadou DICKO 2023-05-27 00:12:57 +02:00 committed by GitHub
parent 1a11f6045e
commit 85f89b4df1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 442 additions and 285 deletions

View File

@ -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 (
<main>
<section className="w-full min-h-screen h-full outline-none flex flex-col gap-5 items-center justify-center p-6">

View File

@ -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() {
/>
<div className="flex flex-col items-center justify-center mt-2 gap-2">
<Button isLoading={isPending}>Sign Up</Button>
<Link href="/login">Already registered? Sign in</Link>
</div>
</form>
</Card>

View File

@ -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<HTMLUListElement> {
setOpen?: Dispatch<SetStateAction<boolean>>;
}
const NavItems: FC<NavItemsProps> = ({ className, setOpen, ...props }) => {
const { session } = useSupabase();
const isUserLoggedIn = session?.user !== undefined;
return (
<ul
className={cn(
@ -44,6 +47,11 @@ const NavItems: FC<NavItemsProps> = ({ className, setOpen, ...props }) => {
<Button variant={"secondary"}>Try Demo</Button>
</Link>
<DarkModeToggle />
{isUserLoggedIn && (
<Link href={"/logout"}>
<Button variant={"secondary"}>Logout</Button>
</Link>
)}
</div>
</ul>
);

View File

@ -0,0 +1,27 @@
import { cn } from "@/lib/utils";
import { FC, HTMLAttributes, LegacyRef, forwardRef } from "react";
type DividerProps = HTMLAttributes<HTMLDivElement> & {
text?: string;
};
const Divider: FC<DividerProps> = forwardRef(
({ className, text, ...props }, ref) => {
return (
<div
ref={ref as LegacyRef<HTMLDivElement>}
className={cn("flex items-center justify-center", className)}
{...props}
>
<hr className="border-t border-gray-300 w-12" />
{text !== undefined && (
<p className="px-3 bg-white text-center text-gray-500">{text}</p>
)}
<hr className="border-t border-gray-300 w-12" />
</div>
);
}
);
Divider.displayName = "AnimatedCard";
export { Divider };

View File

@ -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({
<body
className={`bg-white text-black dark:bg-black dark:text-white min-h-screen w-full ${inter.className}`}
>
<NavBar />
<ToastProvider>
<SupabaseProvider session={session}>{children}</SupabaseProvider>
<SupabaseProvider session={session}>
<NavBar />
{children}
</SupabaseProvider>
</ToastProvider>
<Analytics />
</body>

View File

@ -0,0 +1,8 @@
export const isValidUrl = (string: string) => {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
};

View File

@ -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<HTMLInputElement | null>(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,
};
};

View File

@ -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 (
<div className="w-full">
<div className="flex justify-center gap-5">
<div className="w-1/2">
<div className="flex-column justify-center gap-5">
<Card className="h-32 flex justify-center items-center">
<div className="text-center mt-2 p-6 max-w-sm w-full flex flex-col gap-5 items-center">
<Field
name="crawlurl"
ref={urlInputRef}
type="text"
placeholder="Enter a website URL"
className="w-full"
/>
</div>
<div className="flex flex-col items-center justify-center gap-5">
<Button isLoading={isCrawling} onClick={crawlWebsite}>
Crawl
</Button>
</div>
</Card>
</div>
<Toast ref={messageToast} />
</div>
</div>
</div>
);
};

View File

@ -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<SetStateAction<File[]>>;
}) => {
return (
<motion.div
initial={{ x: "-10%", opacity: 0 }}
animate={{ x: "0%", opacity: 1 }}
exit={{ x: "10%", opacity: 0 }}
className="flex flex-row gap-1 py-2 dark:bg-black border-b border-black/10 dark:border-white/25 leading-none px-6"
>
<div className="flex flex-1">
<div className="flex flex flex-col">
<span className="overflow-ellipsis overflow-hidden whitespace-nowrap">
{file.name}
</span>
<span className="text-xs opacity-50 overflow-hidden text-ellipsis">
{(file.size / 1000).toFixed(3)} kb
</span>
</div>
</div>
<div className="flex w-5">
<button
role="remove file"
className="text-xl text-red-500 text-ellipsis"
onClick={() =>
setFiles((files) =>
files.filter((f) => f.name !== file.name || f.size !== file.size)
)
}
>
<MdClose />
</button>
</div>
</motion.div>
);
};

View File

@ -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<File[]>([]);
const [pendingFileIndex, setPendingFileIndex] = useState<number>(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,
};
};

View File

@ -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 (
<>
<section
{...getRootProps()}
className="w-full outline-none flex flex-col gap-5 items-center justify-center p-6"
>
<div className="w-full">
<div className="flex justify-center gap-5">
{/* Assign a width of 50% to each card */}
<div className="w-1/2">
<Card className="h-52 flex justify-center items-center">
<input {...getInputProps()} />
<div className="text-center mt-2 p-6 max-w-sm w-full flex flex-col gap-5 items-center">
{isDragActive ? (
<p className="text-blue-600">Drop the files here...</p>
) : (
<button
onClick={open}
className="opacity-50 h-full cursor-pointer hover:opacity-100 hover:underline transition-opacity"
>
Drag and drop files here, or click to browse
</button>
)}
</div>
</Card>
</div>
{files.length > 0 && (
<div className="w-1/2">
<Card className="h-52 py-3 overflow-y-auto">
{files.length > 0 ? (
<AnimatePresence>
{files.map((file) => (
<FileComponent
key={file.name + file.size}
file={file}
setFiles={setFiles}
/>
))}
</AnimatePresence>
) : null}
</Card>
</div>
)}
</div>
<div className="flex flex-col items-center justify-center mt-5">
<Button isLoading={isPending} onClick={uploadAllFiles}>
{isPending
? `Uploading ${files[pendingFileIndex].name}`
: "Upload"}
</Button>
</div>
</div>
</section>
<Toast ref={messageToast} />
</>
);
};

View File

@ -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<Message | null>(null);
const messageToast = useRef<ToastRef>(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,
};
};

View File

@ -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<Message | null>(null);
const [isPending, setIsPending] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [pendingFileIndex, setPendingFileIndex] = useState<number>(0);
const urlInputRef = useRef<HTMLInputElement | null>(null);
const { session } = useSupabase();
if (session === null) {
redirect("/login");
}
const messageToast = useRef<ToastRef>(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 (
<main>
<section
{...getRootProps()}
// className="w-full h-full min-h-screen text-center flex flex-col items-center gap-5 pt-32 outline-none"
className="w-full outline-none pt-32 flex flex-col gap-5 items-center justify-center p-6"
>
<PageHeading
title="Upload Knowledge"
subtitle="Text, document, spreadsheet, presentation, audio, video, and URLs supported"
/>
{/* Wrap the cards in a flex container */}
<div className="flex justify-center gap-5">
{/* Assign a width of 50% to each card */}
<Card className="w-1/2">
<input {...getInputProps()} />
<div className="text-center mt-2 p-6 max-w-sm w-full flex flex-col gap-5 items-center">
{files.length > 0 ? (
<AnimatePresence>
{files.map((file) => (
<FileComponent
key={file.name + file.size}
file={file}
setFiles={setFiles}
/>
))}
</AnimatePresence>
) : null}
{isDragActive ? (
<p className="text-blue-600">Drop the files here...</p>
) : (
<button
onClick={open}
className="opacity-50 cursor-pointer hover:opacity-100 hover:underline transition-opacity"
>
Drag and drop files here, or click to browse
</button>
)}
</div>
</Card>
{/* Assign a width of 50% to each card */}
<Card className="w-1/2">
<div className="text-center mt-2 p-6 max-w-sm w-full flex flex-col gap-5 items-center">
<Field
name="crawlurl"
ref={urlInputRef}
type="text"
placeholder="Enter a website URL"
/>
<button
onClick={crawlWebsite}
className="opacity-50 cursor-pointer hover:opacity-100 hover:underline transition-opacity"
>
Crawl
</button>
</div>
</Card>
</div>
<div className="flex flex-col items-center justify-center gap-5">
<Button isLoading={isPending} onClick={uploadAllFiles} className="">
{isPending ? `Uploading ${files[pendingFileIndex].name}` : "Upload"}
<main className="pt-20">
<PageHeading
title="Upload Knowledge"
subtitle="Text, document, spreadsheet, presentation, audio, video, and URLs supported"
/>
<FileUploader />
<Divider text="or" className="m-5" />
<Crawler />
<div className="flex flex-col items-center justify-center gap-5 mt-5">
<Link href={"/chat"}>
<Button variant={"secondary"} className="py-3">
Chat
</Button>
<Link href={"/chat"}>
<Button variant={"secondary"} className="py-3">
Chat
</Button>
</Link>
</div>
</section>
<Toast ref={messageToast} />
</Link>
</div>
</main>
);
}
const FileComponent = ({
file,
setFiles,
}: {
file: File;
setFiles: Dispatch<SetStateAction<File[]>>;
}) => {
return (
<motion.div
initial={{ x: "-10%", opacity: 0 }}
animate={{ x: "0%", opacity: 1 }}
exit={{ x: "10%", opacity: 0 }}
className="flex py-2 dark:bg-black border-b border-black/10 dark:border-white/25 w-full text-left leading-none"
>
<div className="flex flex-col gap-1 flex-1">
<span className="flex-1 mr-10">{file.name}</span>
<span className="text-xs opacity-50">
{(file.size / 1000).toFixed(3)} kb
</span>
</div>
<button
role="remove file"
className="ml-5 text-xl text-red-500 px-5"
onClick={() =>
setFiles((files) =>
files.filter((f) => f.name !== file.name || f.size !== file.size)
)
}
>
<MdClose />
</button>
</motion.div>
);
};
function isValidUrl(string: string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}