mirror of
https://github.com/StanGirard/quivr.git
synced 2024-11-23 21:22:35 +03:00
Toasts (Notification Component) (#163)
* feature: responsive navbar * style: nav links hover animatiosn * style: better Input Fields * refactor: use form submit instead of button onclick * feature: loading states * feature: log out confirmation * feature: basic toast * feature: Toast variants * fix: use global toast provider * feature: use toast instead of alert for auth routes * fix(mobile): nav menu close on route change * fix: field dark mode * feature: redirect when login and logout * refactor: group auth routes * refactor: use @/app imports * style: use Field on /upload * fix: forward ref * feature: Multi toast * feature: add toasts to /upload * refactor: new login in auth group * chore: quote * chore(pnpm): removed * feature: toasty animations * fix: build errors and warnings * chore: remove irrelevant comments * fix: use unique ids for toasts --------- Co-authored-by: Stan Girard <girard.stanislas@gmail.com>
This commit is contained in:
parent
e11183ed07
commit
f69c64ead1
2
.gitignore
vendored
2
.gitignore
vendored
@ -50,4 +50,4 @@ streamlit-demo/.streamlit/secrets.toml
|
||||
.frontend_env
|
||||
backend/pandoc-*
|
||||
**/yarn.lock
|
||||
**/.pandoc-*
|
||||
**/.pandoc-*
|
||||
|
83
frontend/app/(auth)/login/page.tsx
Normal file
83
frontend/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
"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 Link from "next/link";
|
||||
|
||||
export default function Login() {
|
||||
const { supabase } = useSupabase();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const loginToast = useRef<ToastRef>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsPending(true);
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="w-full min-h-screen h-full outline-none flex flex-col gap-5 items-center justify-center p-6">
|
||||
<PageHeading title="Login" subtitle="Welcome back" />
|
||||
<Card className="max-w-md w-full p-5 sm:p-10 text-left">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin();
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Field
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center mt-2 gap-2">
|
||||
<Button isLoading={isPending}>Login</Button>
|
||||
<Link href="/signup">Don{"'"}t have an account? Sign up</Link>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
<Toast variant="success" ref={loginToast} />
|
||||
</main>
|
||||
);
|
||||
}
|
74
frontend/app/(auth)/logout/page.tsx
Normal file
74
frontend/app/(auth)/logout/page.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSupabase } from "@/app/supabase-provider";
|
||||
import Toast, { ToastRef } from "@/app/components/ui/Toast";
|
||||
import PageHeading from "@/app/components/ui/PageHeading";
|
||||
import Button from "@/app/components/ui/Button";
|
||||
import Card from "@/app/components/ui/Card";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Logout() {
|
||||
const { supabase } = useSupabase();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const logoutToast = useRef<ToastRef>(null);
|
||||
const [error, setError] = useState("Unknown Error");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsPending(true);
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error("Error logging out:", error.message);
|
||||
setError(error.message);
|
||||
logoutToast.current?.publish({
|
||||
variant: "danger",
|
||||
text: `Error logging out: ${error.message}`,
|
||||
});
|
||||
} else {
|
||||
console.log("User logged out");
|
||||
logoutToast.current?.publish({
|
||||
variant: "success",
|
||||
text: "Logged out successfully",
|
||||
});
|
||||
router.replace("/");
|
||||
}
|
||||
setIsPending(false);
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// handleLogout();
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="w-full min-h-screen h-full outline-none flex flex-col gap-5 items-center justify-center p-6">
|
||||
<PageHeading title="Logout" subtitle="See you next time" />
|
||||
<Card className="max-w-md w-full p-5 sm:p-10 text-center flex flex-col items-center gap-5">
|
||||
<h2 className="text-lg">Are you sure you want to sign out?</h2>
|
||||
<div className="flex gap-5 items-center justify-center">
|
||||
<Link href={"/"}>
|
||||
<Button variant={"primary"}>Go back</Button>
|
||||
</Link>
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
variant={"danger"}
|
||||
onClick={() => handleLogout()}
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
<Toast variant="success" ref={logoutToast}>
|
||||
Logged Out Successfully
|
||||
</Toast>
|
||||
<Toast variant="danger" ref={logoutToast}>
|
||||
{error}
|
||||
</Toast>
|
||||
</main>
|
||||
);
|
||||
}
|
78
frontend/app/(auth)/signup/page.tsx
Normal file
78
frontend/app/(auth)/signup/page.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
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 { useRef, useState } from "react";
|
||||
|
||||
export default function SignUp() {
|
||||
const { supabase } = useSupabase();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const signupToast = useRef<ToastRef>(null);
|
||||
|
||||
const handleSignUp = async () => {
|
||||
setIsPending(true);
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Error signing up:", error.message);
|
||||
signupToast.current?.publish({
|
||||
variant: "danger",
|
||||
text: `Error signing up: ${error.message}`,
|
||||
});
|
||||
} else if (data) {
|
||||
console.log("User signed up");
|
||||
signupToast.current?.publish({ variant: "success", text: "Sign" });
|
||||
}
|
||||
setIsPending(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="w-full min-h-screen h-full outline-none flex flex-col gap-5 items-center justify-center p-6">
|
||||
<PageHeading title="Sign Up" subtitle="Create your account" />
|
||||
<Card className="max-w-md w-full p-5 sm:p-10 text-left">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignUp();
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Field
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center mt-2 gap-2">
|
||||
<Button isLoading={isPending}>Sign Up</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
<Toast variant="success" ref={signupToast}>
|
||||
<h1 className="font-bold">Confirmation Email sent</h1>
|
||||
<p className="text-sm">Check your email.</p>
|
||||
</Toast>
|
||||
<Toast variant="danger" ref={signupToast} />
|
||||
</main>
|
||||
);
|
||||
}
|
@ -17,9 +17,9 @@ export default function ChatPage() {
|
||||
const [temperature, setTemperature] = useState(0);
|
||||
const [maxTokens, setMaxTokens] = useState(500);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const { supabase, session } = useSupabase()
|
||||
const { supabase, session } = useSupabase();
|
||||
if (session === null) {
|
||||
redirect('/login')
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const askQuestion = async () => {
|
||||
@ -36,7 +36,7 @@ export default function ChatPage() {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -48,7 +48,7 @@ export default function ChatPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full flex flex-col pt-20">
|
||||
<main className="min-h-screen w-full flex flex-col pt-32">
|
||||
<section className="flex flex-col justify-center items-center flex-1 gap-5 h-full">
|
||||
<PageHeading
|
||||
title="Chat with your brain"
|
||||
|
@ -10,7 +10,7 @@ interface MobileMenuProps {}
|
||||
const MobileMenu: FC<MobileMenuProps> = ({}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog.Root onOpenChange={setOpen}>
|
||||
<Dialog.Root onOpenChange={setOpen} open={open}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="block sm:hidden" aria-label="open menu">
|
||||
<MdMenu className="text-4xl" />
|
||||
@ -29,7 +29,10 @@ const MobileMenu: FC<MobileMenuProps> = ({}) => {
|
||||
>
|
||||
<Dialog.Content asChild forceMount>
|
||||
<div className="flex flex-col items-center justify-between py-24 flex-1 w-full bg-white dark:bg-black border border-black/10 dark:border-white/25 p-10 shadow-xl dark:shadow-primary/50 focus:outline-none cursor-auto z-50">
|
||||
<NavItems className="text-3xl h-fit text-center flex-col items-center justify-center gap-10" />
|
||||
<NavItems
|
||||
setOpen={setOpen}
|
||||
className="text-3xl h-fit text-center flex-col items-center justify-center gap-10"
|
||||
/>
|
||||
|
||||
<p className="">
|
||||
Get a Second Brain with{" "}
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { Dispatch, FC, HTMLAttributes, ReactNode, SetStateAction } from "react";
|
||||
import DarkModeToggle from "./DarkModeToggle";
|
||||
import Button from "../ui/Button";
|
||||
|
||||
interface NavItemsProps extends HTMLAttributes<HTMLUListElement> {}
|
||||
interface NavItemsProps extends HTMLAttributes<HTMLUListElement> {
|
||||
setOpen?: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const NavItems: FC<NavItemsProps> = ({ className, ...props }) => {
|
||||
const NavItems: FC<NavItemsProps> = ({ className, setOpen, ...props }) => {
|
||||
return (
|
||||
// <div className={cn("flex flex-1 items-center", className)} {...props}>
|
||||
<ul
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-4 text-sm flex-1",
|
||||
@ -18,14 +19,24 @@ const NavItems: FC<NavItemsProps> = ({ className, ...props }) => {
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_ENV === "local" ? (
|
||||
<>
|
||||
<NavLink to="/upload">Upload</NavLink>
|
||||
<NavLink to="/chat">Chat</NavLink>
|
||||
<NavLink to="/explore">Explore</NavLink>
|
||||
<NavLink setOpen={setOpen} to="/upload">
|
||||
Upload
|
||||
</NavLink>
|
||||
<NavLink setOpen={setOpen} to="/chat">
|
||||
Chat
|
||||
</NavLink>
|
||||
<NavLink setOpen={setOpen} to="/explore">
|
||||
Explore
|
||||
</NavLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavLink to="https://github.com/StanGirard/quivr">Github</NavLink>
|
||||
<NavLink to="https://discord.gg/HUpRgp2HG8">Discord</NavLink>
|
||||
<NavLink setOpen={setOpen} to="https://github.com/StanGirard/quivr">
|
||||
Github
|
||||
</NavLink>
|
||||
<NavLink setOpen={setOpen} to="https://discord.gg/HUpRgp2HG8">
|
||||
Discord
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
<div className="flex sm:flex-1 sm:justify-end flex-col items-center justify-center sm:flex-row gap-5 sm:gap-2">
|
||||
@ -35,19 +46,21 @@ const NavItems: FC<NavItemsProps> = ({ className, ...props }) => {
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</ul>
|
||||
// </div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NavLinkProps {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
setOpen?: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const NavLink: FC<NavLinkProps> = ({ children, to }) => {
|
||||
const NavLink: FC<NavLinkProps> = ({ children, to, setOpen }) => {
|
||||
return (
|
||||
<li className="group relative">
|
||||
<Link href={to}>{children}</Link>
|
||||
<Link onClick={() => setOpen && setOpen(false)} href={to}>
|
||||
{children}
|
||||
</Link>
|
||||
<hr className="aboslute top-full border border-transparent border-b-primary dark:border-b-white scale-x-0 group-hover:scale-x-100 group-focus-within:scale-x-100 transition-transform" />
|
||||
</li>
|
||||
);
|
||||
|
@ -28,7 +28,7 @@ const NavBar: FC<NavBarProps> = ({}) => {
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
}, [hidden]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
|
@ -15,7 +15,7 @@ const ButtonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
"bg-black disabled:bg-gray-500 disabled:hover:bg-gray-500 text-white dark:bg-white dark:text-black hover:bg-gray-700 dark:hover:bg-gray-200 transition-colors",
|
||||
"bg-black border border-black dark:border-white disabled:bg-gray-500 disabled:hover:bg-gray-500 text-white dark:bg-white dark:text-black hover:bg-gray-700 dark:hover:bg-gray-200 transition-colors",
|
||||
tertiary: "text-black dark:text-white bg-transparent py-2 px-4",
|
||||
secondary:
|
||||
"border border-black dark:border-white bg-white dark:bg-black text-black dark:text-white focus:bg-black dark:focus:bg-white hover:bg-black dark:hover:bg-white hover:text-white dark:hover:text-black focus:text-white dark:focus:text-black transition-colors py-2 px-4 shadow-none",
|
||||
|
41
frontend/app/components/ui/Field.tsx
Normal file
41
frontend/app/components/ui/Field.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
InputHTMLAttributes,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
interface FieldProps
|
||||
extends DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> {
|
||||
label?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const Field = forwardRef(
|
||||
({ label, className, name, id, ...props }: FieldProps, forwardedRef) => {
|
||||
return (
|
||||
<fieldset className={cn("flex flex-col w-full", className)} name={name}>
|
||||
{label && (
|
||||
<label htmlFor={name} className="text-sm">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={forwardedRef as RefObject<HTMLInputElement>}
|
||||
className="w-full bg-gray-50 dark:bg-gray-900 px-4 py-2 border rounded-md border-black/10 dark:border-white/25"
|
||||
name={name}
|
||||
id={name}
|
||||
{...props}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
);
|
||||
Field.displayName = "Field";
|
||||
|
||||
export default Field;
|
@ -7,7 +7,7 @@ interface PageHeadingProps {
|
||||
|
||||
const PageHeading: FC<PageHeadingProps> = ({ title, subtitle }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center mt-10">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl font-bold text-center">{title}</h1>
|
||||
{subtitle && <h2 className="opacity-50">{subtitle}</h2>}
|
||||
</div>
|
||||
|
118
frontend/app/components/ui/Toast.tsx
Normal file
118
frontend/app/components/ui/Toast.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
import { ReactNode, forwardRef, useImperativeHandle, useState } from "react";
|
||||
import * as ToastPrimitive from "@radix-ui/react-toast";
|
||||
import Button from "./Button";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { cn, generateUniqueId } from "@/lib/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
export interface ToastRef {
|
||||
publish: (toast: ToastContent) => void;
|
||||
}
|
||||
|
||||
const ToastVariants = cva(
|
||||
"bg-white dark:bg-black px-8 max-w-sm w-full py-5 border border-black/10 dark:border-white/25 rounded-xl shadow-xl flex items-center pointer-events-auto data-[swipe=end]:opacity-0 data-[state=closed]:opacity-0 transition-opacity",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
neutral: "",
|
||||
danger: "bg-red-400 dark:bg-red-600",
|
||||
success: "bg-green-400 dark:bg-green-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "neutral",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface ToastContent extends VariantProps<typeof ToastVariants> {
|
||||
text: string;
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface ToastProps
|
||||
extends ToastPrimitive.ToastProps,
|
||||
VariantProps<typeof ToastVariants> {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const Toast = forwardRef(
|
||||
({ children, variant, ...props }: ToastProps, forwardedRef) => {
|
||||
const [toasts, setToasts] = useState<ToastContent[]>([]);
|
||||
|
||||
const toggleToast = (value: boolean, index: number) => {
|
||||
setToasts((toasts) =>
|
||||
toasts.map((toast, i) => {
|
||||
if (i === index) {
|
||||
toast.open = value;
|
||||
}
|
||||
return toast;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
(): ToastRef => ({
|
||||
publish: (toast: ToastContent) => {
|
||||
setToasts((toasts) => {
|
||||
const newToasts = [...toasts];
|
||||
toast.open = true;
|
||||
toast.id = generateUniqueId();
|
||||
newToasts.push(toast);
|
||||
return newToasts;
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((toast, index) => {
|
||||
if (!toast.open) return;
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
open={toast.open}
|
||||
onOpenChange={(value) => toggleToast(value, index)}
|
||||
asChild
|
||||
forceMount
|
||||
key={toast.id}
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ x: "100%", opacity: 0 }}
|
||||
animate={{
|
||||
x: "0%",
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{ opacity: 0 }}
|
||||
className={cn(ToastVariants({ variant: toast.variant }))}
|
||||
>
|
||||
<ToastPrimitive.Description className="flex-1">
|
||||
{toast.text}
|
||||
</ToastPrimitive.Description>
|
||||
<ToastPrimitive.Close asChild>
|
||||
<Button variant={"tertiary"}>Dismiss</Button>
|
||||
</ToastPrimitive.Close>
|
||||
</motion.div>
|
||||
</ToastPrimitive.Root>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
<ToastPrimitive.Viewport className="fixed flex-col bottom-0 left-0 right-0 p-5 flex items-end gap-2 outline-none pointer-events-none" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ToastProvider = ({ children }: { children?: ReactNode }) => {
|
||||
return <ToastPrimitive.Provider>{children}</ToastPrimitive.Provider>;
|
||||
};
|
||||
|
||||
Toast.displayName = "Toast";
|
||||
|
||||
export default Toast;
|
@ -10,20 +10,14 @@ import { AnimatePresence } from "framer-motion";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
|
||||
|
||||
export default function ExplorePage() {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [isPending, setIsPending] = useState(true);
|
||||
const { supabase, session } = useSupabase();
|
||||
if (session === null) {
|
||||
redirect('/login')
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, []);
|
||||
|
||||
const fetchDocuments = async () => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
@ -34,7 +28,7 @@ export default function ExplorePage() {
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/explore`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -46,12 +40,20 @@ export default function ExplorePage() {
|
||||
setIsPending(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [fetchDocuments]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="w-full outline-none pt-20 flex flex-col gap-5 items-center justify-center p-6">
|
||||
<section className="w-full outline-none pt-32 flex flex-col gap-5 items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center justify-center my-10">
|
||||
<h1 className="text-3xl font-bold text-center">Explore uploaded data</h1>
|
||||
<h2 className="opacity-50">View or delete stored data used by your brain</h2>
|
||||
<h1 className="text-3xl font-bold text-center">
|
||||
Explore uploaded data
|
||||
</h1>
|
||||
<h2 className="opacity-50">
|
||||
View or delete stored data used by your brain
|
||||
</h2>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<Spinner />
|
||||
|
@ -2,9 +2,10 @@ 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 SupabaseProvider from "./supabase-provider";
|
||||
import { createServerComponentSupabaseClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { ToastProvider } from "./components/ui/Toast";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@ -14,16 +15,19 @@ export const metadata = {
|
||||
"Quivr is your second brain in the cloud, designed to easily store and retrieve unstructured information.",
|
||||
};
|
||||
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const supabase = createServerComponentSupabaseClient({
|
||||
headers,
|
||||
cookies,
|
||||
})
|
||||
});
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession()
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
@ -31,10 +35,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
className={`bg-white text-black dark:bg-black dark:text-white min-h-screen w-full ${inter.className}`}
|
||||
>
|
||||
<NavBar />
|
||||
<SupabaseProvider session={session}>{children}</SupabaseProvider>
|
||||
<ToastProvider>
|
||||
<SupabaseProvider session={session}>{children}</SupabaseProvider>
|
||||
</ToastProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Card from "../components/ui/Card";
|
||||
import Button from "../components/ui/Button";
|
||||
import PageHeading from "../components/ui/PageHeading";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Login() {
|
||||
const { supabase } = useSupabase();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const handleLogin = async () => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Error logging in:", error.message);
|
||||
alert(`Error logging in: ${error.message}`);
|
||||
} else if (data) {
|
||||
console.log("User logged in:", data);
|
||||
alert("Login successful!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="w-full outline-none pt-20 flex flex-col gap-5 items-center justify-center p-6">
|
||||
<PageHeading title="Login" subtitle="Welcome back" />
|
||||
<Card className="w-1/2 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">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Email"
|
||||
className="w-full py-2 px-4 rounded-md border border-gray-300 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="w-full py-2 px-4 rounded-md border border-gray-300 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent mt-2"
|
||||
/>
|
||||
<div className="grid place-items-center gap-3">
|
||||
<Button onClick={handleLogin}>Login</Button>
|
||||
<Link href="/signup">{"Don't have an account? Sign up"}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import Card from "../components/ui/Card";
|
||||
import PageHeading from "../components/ui/PageHeading";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
|
||||
export default function Logout() {
|
||||
const { supabase } = useSupabase();
|
||||
|
||||
const handleLogout = async () => {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error("Error logging out:", error.message);
|
||||
alert(`Error logging out: ${error.message}`);
|
||||
} else {
|
||||
console.log("User logged out");
|
||||
alert("Logout successful!");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleLogout();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="w-full outline-none pt-20 flex flex-col gap-5 items-center justify-center p-6">
|
||||
<PageHeading title="Logout" subtitle="See you next time"/>
|
||||
<Card className="w-1/2 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">
|
||||
<h1>You are now logged out.</h1>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Card from "../components/ui/Card";
|
||||
import Button from "../components/ui/Button";
|
||||
import PageHeading from "../components/ui/PageHeading";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
|
||||
export default function SignUp() {
|
||||
const { supabase } = useSupabase();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const handleSignUp = async () => {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Error signing up:", error.message);
|
||||
alert(`Error signing up: ${error.message}`);
|
||||
} else if (data) {
|
||||
console.log("User signed up:", data);
|
||||
alert("Signup successful!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
>
|
||||
<section className="w-full outline-none pt-20 flex flex-col gap-5 items-center justify-center p-6">
|
||||
<PageHeading title="SignUp" subtitle="Create your account"/>
|
||||
|
||||
<Card className="w-1/2 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">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Email"
|
||||
className="text-center"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="text-center"
|
||||
/>
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button onClick={handleSignUp}>Sign Up</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
import { Dispatch, SetStateAction, useCallback, useState, useRef } from "react";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { FileRejection, useDropzone } from "react-dropzone";
|
||||
import axios from "axios";
|
||||
import { Message } from "@/lib/types";
|
||||
@ -11,6 +18,8 @@ import Card from "../components/ui/Card";
|
||||
import PageHeading from "../components/ui/PageHeading";
|
||||
import { useSupabase } from "../supabase-provider";
|
||||
import { redirect } from "next/navigation";
|
||||
import Field from "../components/ui/Field";
|
||||
import Toast, { ToastRef } from "../components/ui/Toast";
|
||||
|
||||
export default function UploadPage() {
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
@ -18,11 +27,26 @@ export default function UploadPage() {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [pendingFileIndex, setPendingFileIndex] = useState<number>(0);
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { supabase, session } = useSupabase()
|
||||
const { supabase, session } = useSupabase();
|
||||
if (session === null) {
|
||||
redirect('/login')
|
||||
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;
|
||||
@ -50,7 +74,7 @@ export default function UploadPage() {
|
||||
config,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -65,36 +89,39 @@ export default function UploadPage() {
|
||||
text: "Failed to crawl website: " + error.toString(),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [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}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
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: any) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "Failed to upload file: " + error.toString(),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
setMessage({
|
||||
type: response.data.type,
|
||||
text:
|
||||
(response.data.type === "success"
|
||||
? "File uploaded successfully: "
|
||||
: "") + JSON.stringify(response.data.message),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "Failed to upload file: " + error.toString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[session.access_token]
|
||||
);
|
||||
|
||||
const onDrop = (acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
if (fileRejections.length > 0) {
|
||||
@ -138,7 +165,7 @@ export default function UploadPage() {
|
||||
<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-20 flex flex-col gap-5 items-center justify-center p-6"
|
||||
className="w-full outline-none pt-32 flex flex-col gap-5 items-center justify-center p-6"
|
||||
>
|
||||
<PageHeading
|
||||
title="Upload Knowledge"
|
||||
@ -177,11 +204,11 @@ export default function UploadPage() {
|
||||
{/* 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">
|
||||
<input
|
||||
<Field
|
||||
name="crawlurl"
|
||||
ref={urlInputRef}
|
||||
type="text"
|
||||
placeholder="Enter a website URL"
|
||||
className="dark:bg-black"
|
||||
/>
|
||||
<button
|
||||
onClick={crawlWebsite}
|
||||
@ -203,19 +230,7 @@ export default function UploadPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
{message && (
|
||||
<div
|
||||
className={`fixed bottom-0 inset-x-0 m-4 p-4 max-w-sm mx-auto rounded ${
|
||||
message.type === "success"
|
||||
? "bg-green-500"
|
||||
: message.type === "warning"
|
||||
? "bg-yellow-600"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
>
|
||||
<p className="text-white">{message.text}</p>
|
||||
</div>
|
||||
)}
|
||||
<Toast ref={messageToast} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
@ -2,5 +2,11 @@ import clsx, { ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function generateUniqueId() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 10000);
|
||||
return `${timestamp}-${random}`;
|
||||
}
|
||||
|
@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.0.3",
|
||||
"@radix-ui/react-toast": "^1.1.3",
|
||||
"@supabase/auth-helpers-nextjs": "^0.6.1",
|
||||
"@supabase/auth-ui-react": "^0.4.2",
|
||||
"@supabase/auth-ui-shared": "^0.1.6",
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user