diff --git a/.vscode/settings.json b/.vscode/settings.json index cf555c5..414859b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "tools", "partnerPage", "footer", - "auth" + "auth", + "voting" ] } \ No newline at end of file diff --git a/components/CategoryMain.tsx b/components/CategoryMain.tsx index 8397e7b..6e85a05 100644 --- a/components/CategoryMain.tsx +++ b/components/CategoryMain.tsx @@ -1,12 +1,37 @@ +import { useUser } from "@supabase/auth-helpers-react"; import { Table } from "flowbite-react"; +import toast, { Toaster } from "react-hot-toast"; +import { supabase } from "../util/supabase"; import EmbeddedSearchbar from "./EmbeddedSearchbar"; import ToolTableRow from "./ToolTableRow"; export default function CategoryMain(props: any) { + const { user, error } = useUser(); + const supabaseCallUser = supabase.auth.user() + + const userInfo = user || supabaseCallUser || null + + + const notifications = { + "noLoginVote": () => toast.error('You need to login first before voting!', {icon: "🙈", position: "bottom-center", duration: 3000}), + "voteAdded": (promise:Promise<{}>) => toast.promise(promise, { + loading: 'Adding vote...', + success: Voted!, + error: Sorry, an error happened. Please try again later. + }, {position: "bottom-center", duration: 2000}).then((r) => r).catch((error) => console.error(error)), + "voteRemoved": (promise:Promise<{}>) => toast.promise(promise, { + loading: 'Remove vote...', + success: Vote removed!, + error: Sorry, an error happened. Please try again later. + }, {position: "bottom-center", duration: 2000}).then((r) => r).catch((error) => console.error(error)), + "voteError": () => toast.error('An error occurred while trying to vote / unvote!', {position: "bottom-center", duration: 3000}), + } + // Show the tool page depending on the id of the tool return (
+
@@ -38,7 +63,7 @@ export default function CategoryMain(props: any) { { props.categoryResults.map((tool: any, index: number) => { return ( - + ) }) } diff --git a/components/EmbeddedSearchbar.tsx b/components/EmbeddedSearchbar.tsx index 1141d50..54eb714 100644 --- a/components/EmbeddedSearchbar.tsx +++ b/components/EmbeddedSearchbar.tsx @@ -3,6 +3,9 @@ import Link from 'next/link' import axios from 'axios' import { useState } from 'react'; import ToolTableRow from './ToolTableRow'; +import toast from 'react-hot-toast'; +import { supabase } from '../util/supabase'; +import { useUser } from '@supabase/auth-helpers-react'; export default function EmbeddedSearchbar(props: any) { @@ -10,6 +13,11 @@ export default function EmbeddedSearchbar(props: any) { const [results, setResults] = useState([]) const [tableVisible, setTableVisibility] = useState(false) + const { user, error } = useUser(); + const supabaseCallUser = supabase.auth.user() + + const userInfo = user || supabaseCallUser || null + const showSearchResults = async (searchTerm: string) => { if (searchTerm.length > 3){ @@ -31,6 +39,21 @@ export default function EmbeddedSearchbar(props: any) { } + const notifications = { + "noLoginVote": () => toast.error('You need to login first before voting!', {icon: "🙈", position: "bottom-center", duration: 3000}), + "voteAdded": (promise:Promise<{}>) => toast.promise(promise, { + loading: 'Adding vote...', + success: Voted!, + error: Sorry, an error happened. Please try again later. + }, {position: "bottom-center", duration: 2000}).then((r) => r).catch((error) => console.error(error)), + "voteRemoved": (promise:Promise<{}>) => toast.promise(promise, { + loading: 'Remove vote...', + success: Vote removed!, + error: Sorry, an error happened. Please try again later. + }, {position: "bottom-center", duration: 2000}).then((r) => r).catch((error) => console.error(error)), + "voteError": () => toast.error('An error occurred while trying to vote / unvote!', {position: "bottom-center", duration: 3000}), + } + return (
0 ? ( results.map((row: any, index: number) => { return ( - + ) }) ) : ( diff --git a/components/Header.tsx b/components/Header.tsx index c1f3a0b..3eca393 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,14 +1,37 @@ -import {useState} from "react"; import Image from "next/image" import Link from "next/link"; import Logo from "../assets/default-monochrome-white.svg" import { SiDiscord, SiTwitter } from "react-icons/si"; -import { Button } from "flowbite-react"; +import { Avatar, Button, Dropdown, Spinner } from "flowbite-react"; import LoginModal from "./LoginModal"; +import { useUser } from '@supabase/auth-helpers-react'; +import { supabase } from "../util/supabase"; +import { Suspense, useEffect, useState } from 'react'; -export default function Header() { +export default function Header(props:any) { + const [navbarOpen, setNavbarOpen] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const { user, error } = useUser(); + const [ userData, setUserData] : any = useState({username: "loading..."}); + + const supabaseCallUser = supabase.auth.user() + + useEffect(() => { + setIsLoading(false); + async function loadData(user:any) { + const { data } : any = await supabase.from('profiles').select('*').eq("user_id", user?.id); + setUserData(data[0]); + } + if (user) { + loadData(user); + } + if(supabaseCallUser){ + loadData(supabaseCallUser) + } + }, [user]); return (
@@ -103,12 +126,44 @@ export default function Header() { > GitHub Repo stars + { isLoading ? () : ( +
}> + { supabaseCallUser ? ( + + ) : {supabaseCallUser.user_metadata.full_name || userData.username || supabaseCallUser.email} }> + + + {supabaseCallUser.user_metadata.full_name || userData.username || supabaseCallUser.email } + + + {supabaseCallUser.email} + + + + + Settings + + + {supabase.auth.signOut(); window.location.href = "/";}}> + Sign out + + + + ) : ( + )} + + )} diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 8fe772f..367935c 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -5,132 +5,15 @@ import { supabase } from "../util/supabase"; export default function LoginModal(props: any){ - const [showRegisterContent, setShowRegisterContent] = useState(false) const [showAlert, setShowAlert] = useState(false) const [showSuccessAlert, setShowSuccessAlert] = useState(false) - const [alertMessage, setAlertMessage] = useState("") - const initialLoginData = Object.freeze({ - email: "", - password: "" - }); - - const initialRegisterData = Object.freeze({ - registerUsername: "", - registerEmail: "", - registerRepeatEmail: "", - registerPassword: "" - }); - - const [formData, updateFormData] = useState(initialLoginData); - const [formRegisterData, updateRegisterFormData] = useState(initialRegisterData); - - const handleChange = (e:any) => { - updateFormData({ - ...formData, - // Trimming any whitespace - [e.target.name]: e.target.value.trim() - }); - }; - - const handleRegisterChange = (e:any) => { - updateRegisterFormData({ - ...formRegisterData, - // Trimming any whitespace - [e.target.name]: e.target.value.trim() - }); - }; - - async function recoverPassword() { - if(formData.email.trim() === ""){ - setShowAlert(true) - setAlertMessage("Please enter an email address in the email field") - }else if(!formData.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)){ - setShowAlert(true) - setAlertMessage("Please enter a valid email address") - }else{ - - const { data, error } = await supabase.auth.api.resetPasswordForEmail(formData.email); - - if(error){ - setShowAlert(true) - setAlertMessage(error.message) - }else{ - setShowAlert(false) - setShowSuccessAlert(true) - setAlertMessage("Password reset email sent. Please check your email.") - // TODO: Create a basic site for this and link to it in the email - } - - } - } - - async function signInWithEmail() { - - if(formData.email.trim() === ""){ - setShowAlert(true) - setAlertMessage("Please enter an email address") - }else if(formData.password.trim() === ""){ - setShowAlert(true) - setAlertMessage("Please enter a password") - }else{ - const { user, error } = await supabase.auth.signIn({ - email: formData.email, - password: formData.password, - }) - - if(error){ - setShowAlert(true) - setAlertMessage(error.message) - } - - if(user){ - setShowAlert(false) - setShowSuccessAlert(true) - setAlertMessage("Successfully signed in") - window.location.reload() - } - } - } - - async function registerWithEmail() { - const { user, error } = await supabase.auth.signUp({ - email: formRegisterData.registerEmail, - password: formRegisterData.registerPassword, - }) - - if(error){ - setShowAlert(true) - setAlertMessage(error.message) - } - - if(user){ - const {data, error: error2} = await supabase.from("profiles").insert({ - username: formRegisterData.registerUsername, - user_id: user.id - }) - - if(error2){ - setShowAlert(true) - setAlertMessage(error2.message) - } - - if(data){ - setShowAlert(false) - setShowSuccessAlert(true) - setAlertMessage("Successfully registered. Please check your email to verify your account.") - } - } - - } - async function signInWithGithub() { const { user, session, error } = await supabase.auth.signIn({ provider: 'github', }) } - return (
@@ -144,7 +27,7 @@ export default function LoginModal(props: any){

- { showRegisterContent ? "Register" : "Sign in" } to toolDB + Sign in to toolDB

{ showAlert ? ( {alertMessage} ) : null } - { !showRegisterContent ? ( - <> -
-
-
- -
- -
-
-
- -
- -
- -
- -
- -
- - ) : ( - <> -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- -
- -
- - )} -
-

or

-
- +
+ +
diff --git a/components/Main.tsx b/components/Main.tsx index 23412b3..706c16d 100644 --- a/components/Main.tsx +++ b/components/Main.tsx @@ -4,6 +4,7 @@ import { Card, Progress, Spinner } from 'flowbite-react'; import Link from 'next/link'; import useSWR from 'swr'; import axios from 'axios'; +import { Toaster } from 'react-hot-toast'; const fetcher = (url:any) => axios.get(url).then(res => res.data) @@ -35,6 +36,7 @@ export default function Main(props: any) { return (
+

Search the tool
you need for your project diff --git a/components/ToolMain.tsx b/components/ToolMain.tsx index 03b4ddd..b6990d6 100644 --- a/components/ToolMain.tsx +++ b/components/ToolMain.tsx @@ -1,7 +1,7 @@ import { Avatar, Badge, Button, Carousel, Modal, Spinner, Tooltip } from "flowbite-react"; import Link from "next/link"; -import { Suspense, useState } from "react"; -import { FaDiscord, FaExclamationCircle, FaGithub, FaLink, FaStar, FaTwitter } from "react-icons/fa"; +import { Suspense, useEffect, useState } from "react"; +import { FaArrowUp, FaDiscord, FaExclamationCircle, FaGithub, FaLink, FaRegTimesCircle, FaStar, FaTwitter } from "react-icons/fa"; import { RiFilePaper2Fill } from "react-icons/ri"; import { CgGitFork } from "react-icons/cg"; import EmbeddedSearchbar from "./EmbeddedSearchbar"; @@ -11,15 +11,85 @@ import Vibrant from "node-vibrant" import ReactMarkdown from 'react-markdown' import gfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' +import { Toaster } from "react-hot-toast"; +import axios from "axios"; +import useSWR from 'swr'; + +const fetcher = (url:any) => axios.get(url).then(res => res.data) export default function ToolMain(props: any) { const [vibrantColors, setVibrantColors] = useState([]); const [modals, setModal] = useState([]); + const [toolVotes, setVotes] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [isVoted, setIsVoted] = useState(false); + const userInfo = props.userInfo + + const { data: votes, error: voteCountError }: any = useSWR(`/api/getVotes/${props.toolData.id}`, fetcher) + + useEffect(() => { + if(votes){ + if(userInfo){ + axios.get(`/api/tools/vote?id=${props.toolData.id}`).then(res => { + if(res.data.voted){ + setIsVoted(true) + setVotes(votes.votes) + setIsLoading(false) + }else{ + setIsVoted(false) + setVotes(votes.votes) + setIsLoading(false) + } + }).catch(err => { + console.log(err) + setVotes(votes.votes) + setIsLoading(false) + }) + }else{ + setVotes(votes.votes) + setIsLoading(false) + } + } + }, [votes, userInfo, props.toolData.id]) + + async function handleVote (e:any) { + if(votes && userInfo && !isLoading && !voteCountError){ + const oldVotes = toolVotes + if(isVoted){ + try { + setVotes(toolVotes - 1) + setIsVoted(false) + const removeVote = axios.post(`/api/tools/vote?id=${props.toolData.id}`, { + userId: userInfo.id, + }) + props.notifications.voteRemoved(removeVote); + } catch (error) { + setVotes(oldVotes) + props.notifications.voteError(); + } + }else{ + try { + setVotes(toolVotes + 1) + setIsVoted(true) + const addVote = axios.post(`/api/tools/vote?id=${props.toolData.id}`, { + userId: userInfo.id, + }) + props.notifications.voteAdded(addVote); + } catch (error) { + setVotes(oldVotes) + props.notifications.voteError(); + } + } + }else{ + props.notifications.noLoginVote(); + } + } // Show the tool page depending on the id of the tool return (
+
@@ -32,6 +102,10 @@ export default function ToolMain(props: any) {

{props.toolData.tool_name} +

{ diff --git a/components/ToolSearchTable.tsx b/components/ToolSearchTable.tsx index 434f349..6452087 100644 --- a/components/ToolSearchTable.tsx +++ b/components/ToolSearchTable.tsx @@ -1,10 +1,13 @@ -import { Button, Pagination, Progress, Spinner, Table, TextInput } from 'flowbite-react' +import { Button, Pagination, Progress, Spinner, Table, TextInput, Toast } from 'flowbite-react' import Link from 'next/link' import axios from 'axios' import { useEffect, useRef, useState } from 'react'; import { FaStar, FaTable } from 'react-icons/fa'; import useSWR from 'swr'; import ToolTableRow from './ToolTableRow'; +import { useUser } from '@supabase/auth-helpers-react'; +import { supabase } from "../util/supabase"; +import toast from 'react-hot-toast'; const fetcher = (url:any) => axios.get(url).then(res => res.data) @@ -12,6 +15,11 @@ export default function ToolSearchTable(props: any) { let maxPages = useRef(0); + const { user, error } = useUser(); + const supabaseCallUser = supabase.auth.user() + + const userInfo = user || supabaseCallUser || null + const [tablePreviewData, setTablePreviewData] = useState([]) const [standardTableViewVisible, setStandardTableViewVisible] = useState(true) const [recentlyAddedTableViewVisible, setRecentlyAddedTableViewVisible] = useState(false) @@ -117,6 +125,21 @@ export default function ToolSearchTable(props: any) { } + const notifications = { + "noLoginVote": () => toast.error('You need to login first before voting!', {icon: "🙈", position: "bottom-center", duration: 3000}), + "voteAdded": (promise:Promise<{}>) => toast.promise(promise, { + loading: 'Adding vote...', + success: Voted!, + error: Sorry, an error happened. Please try again later. + }, {position: "bottom-center", duration: 2000}).then((r) => r).catch((error) => console.error(error)), + "voteRemoved": (promise:Promise<{}>) => toast.promise(promise, { + loading: 'Remove vote...', + success: Vote removed!, + error: Sorry, an error happened. Please try again later. + }, {position: "bottom-center", duration: 2000}).then((r) => r).catch((error) => console.error(error)), + "voteError": () => toast.error('An error occurred while trying to vote / unvote!', {position: "bottom-center", duration: 3000}), + } + return ( <>

Search through {maxPages.current || } tools

@@ -175,7 +198,7 @@ export default function ToolSearchTable(props: any) { tablePreviewData ? ( tablePreviewData.map((row: any, index: number) => { return ( - + ) }) ) : ( diff --git a/components/ToolTableRow.tsx b/components/ToolTableRow.tsx index 6fae193..7edb523 100644 --- a/components/ToolTableRow.tsx +++ b/components/ToolTableRow.tsx @@ -1,20 +1,90 @@ -import { Avatar, Badge, BadgeColor, Button, Table, Tooltip } from 'flowbite-react' +import axios from 'axios'; +import { Avatar, Badge, BadgeColor, Button, Spinner, Table, Tooltip } from 'flowbite-react' import Link from 'next/link'; -import { FaArrowUp, FaDiscord, FaGithub, FaLink, FaStar, FaTwitter } from 'react-icons/fa'; +import { useEffect, useState } from 'react'; +import { FaArrowUp, FaDiscord, FaGithub, FaLink, FaRegTimesCircle, FaStar, FaTwitter } from 'react-icons/fa'; +import useSWR from 'swr'; + +const fetcher = (url:any) => axios.get(url).then(res => res.data) export default function ToolTableRow(props: any) { const row = props.row; const showCategories = props.showCategories ? true : false; const showSubmittedBy = props.showSubmittedBy; + const userInfo = props.userInfo + + const [toolVotes, setVotes] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [isVoted, setIsVoted] = useState(false); + + const { data: votes, error: voteCountError }: any = useSWR(`/api/getVotes/${row.id}`, fetcher) + + useEffect(() => { + if(votes){ + if(userInfo){ + axios.get(`/api/tools/vote?id=${row.id}`).then(res => { + if(res.data.voted){ + setIsVoted(true) + setVotes(votes.votes) + setIsLoading(false) + }else{ + setIsVoted(false) + setVotes(votes.votes) + setIsLoading(false) + } + }).catch(err => { + console.log(err) + setVotes(votes.votes) + setIsLoading(false) + }) + }else{ + setVotes(votes.votes) + setIsLoading(false) + } + } + }, [votes, userInfo, row.id]) + + async function handleVote (e:any) { + if(votes && userInfo && !isLoading && !voteCountError){ + const oldVotes = toolVotes + if(isVoted){ + try { + setVotes(toolVotes - 1) + setIsVoted(false) + const removeVote = axios.post(`/api/tools/vote?id=${row.id}`, { + userId: userInfo.id, + }) + props.notifications.voteRemoved(removeVote); + } catch (error) { + setVotes(oldVotes) + props.notifications.voteError(); + } + }else{ + try { + setVotes(toolVotes + 1) + setIsVoted(true) + const addVote = axios.post(`/api/tools/vote?id=${row.id}`, { + userId: userInfo.id, + }) + props.notifications.voteAdded(addVote); + } catch (error) { + setVotes(oldVotes) + props.notifications.voteError(); + } + } + }else{ + props.notifications.noLoginVote(); + } + } return (
- diff --git a/pages/_app.tsx b/pages/_app.tsx index b0ddd95..8bfc68a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,8 +1,14 @@ import '../styles/globals.css' import type { AppProps } from 'next/app' +import { UserProvider } from '@supabase/auth-helpers-react'; +import { supabase } from '../util/supabase'; function MyApp({ Component, pageProps }: AppProps) { - + return ( + + + + ); } export default MyApp diff --git a/pages/profile/passwordRecovery.tsx b/pages/profile/passwordRecovery.tsx new file mode 100644 index 0000000..ceabbed --- /dev/null +++ b/pages/profile/passwordRecovery.tsx @@ -0,0 +1,230 @@ +import Head from "next/head"; +import Header from "../../components/Header"; +import Footer from "../../components/Footer"; +import { NextSeo } from "next-seo"; +import Script from "next/script"; +import { useEffect, useState } from "react"; +import { supabase } from "../../util/supabase"; +import { Alert, Button, TextInput } from "flowbite-react"; +import toast, { Toaster } from "react-hot-toast"; + +export default function PasswordRecovery(props: any) { + + const initialPasswordRecovery = Object.freeze({ + newPassword: "", + newRepeatPassword: "" + }); + + const [passwordRecoveryData, updatePasswordRecoveryData] = useState(initialPasswordRecovery); + const [submitted, setSubmitted] = useState(true); + const [userData, setUserData] : any = useState({}); + const [accessToken, setAccessToken] = useState(""); + + useEffect(() => { + + const hashValues = window.location.hash.substring(1).split("&").map((param) => param.split("=")); + + let type; + let accessToken; + + for (const [key, value] of hashValues) { + if (key === "type") { + type = value; + } else if (key === "access_token") { + accessToken = value; + } + } + + console.log(type, accessToken); + console.log(hashValues) + + if (type !== "recovery" || !accessToken || typeof accessToken === "object") { + toast.error("Invalid credentials. Redirecting to homepage", { + duration: 2000 + }); + setTimeout(() => { + window.location.href = "/"; + }, 100000); + return; + } + + setAccessToken(accessToken); + + const data = supabase.auth.api.getUser( + accessToken, + ).then((user) => { + setUserData(user); + if(user?.user?.app_metadata.provider === "github"){ + toast.error("Github users cannot change their password. Redirecting to homepage", { + duration: 2000 + }); + setTimeout(() => { + window.location.href = "/"; + }, 1000); + return; + }else{ + setSubmitted(false); + return; + } + }).catch((error) => { + toast.error("An error occured. Redirecting to homepage", { + duration: 2000 + }); + setTimeout(() => { + window.location.href = "/"; + }, 1000); + return; + }) + + }, []); + + const handleChange = (e:any) => { + updatePasswordRecoveryData({ + ...passwordRecoveryData, + // Trimming any whitespace + [e.target.name]: e.target.value.trim() + }); + }; + + const handleSubmit = async (e:any) => { + e.preventDefault(); + + const notification = toast.loading("Changing password"); + + setSubmitted(true); + + try { + if (passwordRecoveryData.newPassword !== passwordRecoveryData.newRepeatPassword) { + toast.error("Passwords do not match", {id: notification, duration: 2000}); + return; + } + + const { error } = await supabase.auth.api.updateUser(accessToken, { password : passwordRecoveryData.newPassword }) + + if (error) { + toast.error(error.message, { + id: notification, + duration: 2000 + }); + } else if (!error) { + toast.success("Password successfully changed. Redirecting to homepage", { + id: notification, + duration: 2000, + }); + setTimeout(() => { + window.location.href = "/"; + }, 2000); + } + + } catch (error) { + toast.error("An error occured. Please request another password recovery attempt. You will be redirected", { + id: notification, + duration: 2000 + }); + setTimeout(() => { + window.location.href = "/"; + }, 4000); + } + } + return ( +
+ + + + + + +