From ab8a4e4e2ca911bfd0199ca5e852eb32b64f7820 Mon Sep 17 00:00:00 2001 From: "Siaw A. Nicholas" Date: Thu, 4 Apr 2024 04:07:15 +0000 Subject: [PATCH 1/5] feat: add toaster component for displaying toast notifications --- client/package.json | 1 + client/src/app/layout.tsx | 2 + client/src/components/ui/toast.tsx | 129 +++++++++++++++++ client/src/components/ui/toaster.tsx | 35 +++++ client/src/components/ui/use-toast.ts | 194 ++++++++++++++++++++++++++ 5 files changed, 361 insertions(+) create mode 100644 client/src/components/ui/toast.tsx create mode 100644 client/src/components/ui/toaster.tsx create mode 100644 client/src/components/ui/use-toast.ts diff --git a/client/package.json b/client/package.json index 7bce670..c07ac56 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "axios": "^1.6.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index db45625..3085254 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -10,6 +10,7 @@ import { Separator } from "~/components/ui/separator"; import { ClerkProvider } from "@clerk/nextjs"; import ThemeToggle from "~/components/core/dark-mode-toggle"; import { dark } from "@clerk/themes"; +import { Toaster } from "~/components/ui/toaster" const inter = Inter({ subsets: ["latin"] }); @@ -52,6 +53,7 @@ export default function RootLayout({ + diff --git a/client/src/components/ui/toast.tsx b/client/src/components/ui/toast.tsx new file mode 100644 index 0000000..b4bf1ad --- /dev/null +++ b/client/src/components/ui/toast.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "~/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/client/src/components/ui/toaster.tsx b/client/src/components/ui/toaster.tsx new file mode 100644 index 0000000..8b67a2d --- /dev/null +++ b/client/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "~/components/ui/toast" +import { useToast } from "~/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/client/src/components/ui/use-toast.ts b/client/src/components/ui/use-toast.ts new file mode 100644 index 0000000..d6698ef --- /dev/null +++ b/client/src/components/ui/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "~/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } From 4a6a37d689c2caee965a68cf40ef2be078506888 Mon Sep 17 00:00:00 2001 From: "Siaw A. Nicholas" Date: Thu, 4 Apr 2024 04:09:25 +0000 Subject: [PATCH 2/5] feat: refactor form and add image attachment functionality --- client/src/app/page.tsx | 180 +++++++++++++++++--------- client/src/components/posts/posts.tsx | 2 +- 2 files changed, 123 insertions(+), 59 deletions(-) diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index 7e7fc52..7c59673 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -25,41 +25,72 @@ import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import useSWR from "swr"; import { useFetcher } from "~/hooks/fetcher"; -import {useAuth} from '@clerk/clerk-react' -import { get } from "http"; +import { useAuth } from "@clerk/clerk-react"; +import { Input } from "~/components/ui/input"; +import { useToast } from "~/components/ui/use-toast" +import { ToastAction } from "~/components/ui/toast"; +import Link from "next/link"; + const formSchema = z.object({ forum: z.string({ required_error: "Forum is required", }), - content: z.string({ - required_error: "Content is required", - }), + imageUrl: z.string().default(""), }); + export default function Home() { + const {toast} = useToast(); const [value, setValue] = useState(""); const { quill, quillRef } = useQuill(); const [forum, setForum] = useState(""); + // const [imageUrl, setImageUrl] = useState(""); const { allForums, createPost } = useFetcher(); - const { data: forums, error } = useSWR("/forums", allForums); + const { data: forums, error, mutate } = useSWR("/forums", allForums); const form = useForm>({ resolver: zodResolver(formSchema), }); - const {isSignedIn, getToken} = useAuth() + const { isSignedIn, getToken } = useAuth(); async function onSubmit(data: z.infer) { + console.log("data"); const token = await getToken(); if (token === null) { - console.error('Token is null'); + console.error("Token is null"); return; } - console.log(token); - const res = await createPost({ - title: value.slice(0, 10), - content: value, - forumId: forums?.find((forum) => forum.slug === form.getValues('forum')) - ?.id, - }, token); + try { + const res = await createPost( + { + title: value.slice(0, 10), + content: value, + forumId: forums?.find((forum) => forum.slug === form.getValues("forum")) + ?.id, + imageUrl: form.getValues("imageUrl"), + }, + token + ); + console.log(res); + toast({ + title: "Post created sucessfully", + description: "Your post has been created", + action: + + View post + + , + }); + form.reset(); + quill?.setText(""); + mutate(); + } + catch (e) { + console.error(e); + toast({ + title: "Error creating post", + description: "An error occurred while creating your post", + }); + } } useEffect(() => { @@ -74,51 +105,84 @@ export default function Home() { // setForum(event.target.value); // } - return (
- {forums &&
- {isSignedIn &&
- - ( - - Forum - - - )} - > -
- - - } -
} + {forums && ( +
+ {isSignedIn && ( +
+ +
+ ( + + Forum + + + )} + > + ( + + Attach image + +
+ + {/* */} +
+
+
+ )} + >
+
+
+ + + + )} +
+ )}
); diff --git a/client/src/components/posts/posts.tsx b/client/src/components/posts/posts.tsx index 3ad4071..d18fc1c 100644 --- a/client/src/components/posts/posts.tsx +++ b/client/src/components/posts/posts.tsx @@ -16,7 +16,7 @@ export default function Posts() { return ( -
+
{data && data.map((post) => ( <> From d24e2b5f145104a043735709850e46172e403042 Mon Sep 17 00:00:00 2001 From: "Siaw A. Nicholas" Date: Thu, 4 Apr 2024 04:32:03 +0000 Subject: [PATCH 3/5] feat: update post sorting in recentPosts and posts components --- client/src/components/core/recent-posts.tsx | 2 +- client/src/components/posts/posts.tsx | 2 +- client/src/hooks/fetcher.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/core/recent-posts.tsx b/client/src/components/core/recent-posts.tsx index d9dde5a..e9295a0 100644 --- a/client/src/components/core/recent-posts.tsx +++ b/client/src/components/core/recent-posts.tsx @@ -8,7 +8,7 @@ import { useFetcher } from "~/hooks/fetcher"; export default function RecentPosts() { const { allPosts } = useFetcher(); - const { data, error } = useSWR("/posts", allPosts); + const { data, error } = useSWR("asc", allPosts); return (
diff --git a/client/src/components/posts/posts.tsx b/client/src/components/posts/posts.tsx index d18fc1c..e8a5170 100644 --- a/client/src/components/posts/posts.tsx +++ b/client/src/components/posts/posts.tsx @@ -12,7 +12,7 @@ export default function Posts() { const { allPosts } = useFetcher(); const { isSignedIn } = useUser(); - const { data, error } = useSWR("/posts", allPosts); + const { data, error } = useSWR("desc", allPosts); return ( diff --git a/client/src/hooks/fetcher.tsx b/client/src/hooks/fetcher.tsx index e0d7e12..b9cf8a9 100644 --- a/client/src/hooks/fetcher.tsx +++ b/client/src/hooks/fetcher.tsx @@ -5,9 +5,9 @@ import { Forum, ForumPost } from "~/types/Forum"; import { mutate } from 'swr'; export const useFetcher = (filter = 'hot') => { - const allPosts = async (): Promise => { + const allPosts = async (sort: string): Promise => { try { - const response = await axios.get('/posts'); + const response = await axios.get(!sort ? '/posts' : `/posts?order=${sort}`); return response.data; } catch (error) { // @ts-ignore From 5dedd5cb90e71e8cc101850474345ace95dcc6d7 Mon Sep 17 00:00:00 2001 From: "Siaw A. Nicholas" Date: Thu, 4 Apr 2024 11:56:36 +0000 Subject: [PATCH 4/5] refactor: refactor useswr hook in posts component --- client/src/components/posts/posts.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/src/components/posts/posts.tsx b/client/src/components/posts/posts.tsx index e8a5170..61cb1b4 100644 --- a/client/src/components/posts/posts.tsx +++ b/client/src/components/posts/posts.tsx @@ -6,14 +6,13 @@ import { useUser } from "@clerk/clerk-react"; import Post from "./post"; import { Separator } from "../ui/separator"; - - export default function Posts() { const { allPosts } = useFetcher(); const { isSignedIn } = useUser(); - const { data, error } = useSWR("desc", allPosts); - + const { data, error, mutate } = useSWR("allPost", () => allPosts("desc"), { + refreshInterval: 3000 + }); return (
From 56601a79b0a40aa6f5b5b643c6e827094766547d Mon Sep 17 00:00:00 2001 From: "Siaw A. Nicholas" Date: Thu, 4 Apr 2024 11:57:51 +0000 Subject: [PATCH 5/5] feat: refactor form and toast imports and remove unused code --- client/src/app/page.tsx | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index 7c59673..9b0c819 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -15,11 +15,9 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, - FormMessage, } from "~/components/ui/form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -27,11 +25,10 @@ import useSWR from "swr"; import { useFetcher } from "~/hooks/fetcher"; import { useAuth } from "@clerk/clerk-react"; import { Input } from "~/components/ui/input"; -import { useToast } from "~/components/ui/use-toast" +import { useToast } from "~/components/ui/use-toast"; import { ToastAction } from "~/components/ui/toast"; import Link from "next/link"; - const formSchema = z.object({ forum: z.string({ required_error: "Forum is required", @@ -40,13 +37,11 @@ const formSchema = z.object({ }); export default function Home() { - const {toast} = useToast(); + const { toast } = useToast(); const [value, setValue] = useState(""); const { quill, quillRef } = useQuill(); - const [forum, setForum] = useState(""); - // const [imageUrl, setImageUrl] = useState(""); const { allForums, createPost } = useFetcher(); - const { data: forums, error, mutate } = useSWR("/forums", allForums); + const { data: forums, error } = useSWR("/forums", allForums); const form = useForm>({ resolver: zodResolver(formSchema), }); @@ -64,27 +59,26 @@ export default function Home() { { title: value.slice(0, 10), content: value, - forumId: forums?.find((forum) => forum.slug === form.getValues("forum")) - ?.id, + forumId: forums?.find( + (forum) => forum.slug === form.getValues("forum") + )?.id, imageUrl: form.getValues("imageUrl"), }, token ); - console.log(res); toast({ title: "Post created sucessfully", description: "Your post has been created", - action: - - View post - - , + action: ( + + View post + + ), }); form.reset(); + quill?.setText(""); - mutate(); - } - catch (e) { + } catch (e) { console.error(e); toast({ title: "Error creating post", @@ -101,10 +95,6 @@ export default function Home() { } }, [quill]); - // const handleChange = (event) => { - // setForum(event.target.value); - // } - return (
{forums && (