iT邦幫忙

2023 iThome 鐵人賽

DAY 27
0
自我挑戰組

模仿知名網站的外觀系列 第 27

【Day27】模仿知名網站的外觀 X(14) 評論和通知

  • 分享至 

  • xImage
  •  

在pages/api資料夾下,新增comments.tsx,提供一個讓用戶在貼文下發表評論的API。

import { NextApiRequest, NextApiResponse } from "next";
import serverAuth from "@/libs/serverAuth";
import prisma from "@/libs/prismadb";

export default async function handler(
	req: NextApiRequest,
	res: NextApiResponse
) {
	if (req.method !== "POST") {
		return res.status(405).end();
	}

	try {
        const { currentUser } = await serverAuth(req, res);
        const { content } = req.body;
        const { postId } = req.query;

        if(!postId || typeof postId !== 'string'){
            throw new Error('Invalid ID');
        }

        const commment = await prisma.comment.create({
            data: {
                content,
                userId: currentUser.id,
                postId
            }
        });

        return res.status(200).json(commment);
	} catch (error) {
		console.log(error);
		return res.status(400).end();
	}
}

修改Form.tsx,將它打造成可以用來發表貼文和評論的版面,評論後頁面會立刻顯示剛發佈的評論。

import useCurrentUser from "@/Hooks/useCurrentUser";
import useLoginModal from "@/Hooks/useLoginModal";
import usePosts from "@/Hooks/usePosts";
import useRegisterModal from "@/Hooks/useRegisterModal";
import axios from "axios";
import { useCallback, useState } from "react";
import toast from "react-hot-toast";
import Button from "./Button";
import Avatar from "./Avatar";
import usePost from "@/Hooks/usePost";

interface FormProps {
	placeholder: string;
	isComment?: boolean;
	postId?: string;
}

const Form: React.FC<FormProps> = ({ placeholder, isComment, postId }) => {
	const registerModal = useRegisterModal();
	const loginModal = useLoginModal();

	const { data: currentUser } = useCurrentUser();
	const { mutate: mutatePosts } = usePosts();
	const { mutate: mutatePost } = usePost(postId as string);

	const [content, setContent] = useState("");
	const [isLoading, setIsLoading] = useState(false);

	const onSubmit = useCallback(async () => {
		try {
			setIsLoading(true);
			const url = isComment ? `/api/comments?postId=${postId}` : "/api/posts";
			await axios.post(url, { content });
			toast.success("Tweet Created");
			setContent("");
			mutatePosts();
			mutatePost();
		} catch (error) {
			toast.error("Something went wrong");
		} finally {
			setIsLoading(false);
		}
	}, [content, mutatePosts, isComment, postId, mutatePost]);
	return (
		<div className="border-b-[1px] border-neutral-800 px-5 py-2">
			{currentUser ? (
				<div className="flex flex-row gap-4">
                    <div>
                        <Avatar userId={currentUser?.id}/>
                    </div>
                    <div className="w-full">
                        <textarea
                            disabled={isLoading}
                            onChange={(e) => setContent(e.target.value)}
                            value={content}
                            className="disabled:opacity-80 peer resize-none mt-3 w-full bg-black ring-0 outline-none text-[20px] placeholder-neutral-500 text-white"
                            placeholder={placeholder}
                        >
                        </textarea>
                        <hr className="opacity-0 peer-focus:opacity-100 h-[1px] w-full border-neutral-800 transition" />
                        <div className="mt-4 flex flex-row justify-end">
                            <Button 
                                disabled={isLoading || !content}
                                onClick={onSubmit}
                                label="Post"
                            />
                        </div>
                    </div>
                </div>
			) : (
				<div className="py-8">
					<h1 className="text-white text-2xl text-center mb-4 font-bold">
						Welcome to X
					</h1>
					<div className="flex flex-row items-center justify-center gap-4">
						<Button label="Login" onClick={loginModal.onOpen} />
						<Button label="Register" onClick={registerModal.onOpen} secondary />
					</div>
				</div>
			)}
		</div>
	);
};

export default Form;

修改[postId].tsx,顯示這篇貼文下的所有評論。

import { useRouter } from "next/router";
import { ClipLoader } from "react-spinners";
import usePost from "@/Hooks/usePost";
import Header from "@/Components/layout/Header";
import PostItem from "@/Components/posts/PostItem";
import Form from "@/Components/Form";
import CommentFeed from "@/Components/posts/CommentFeed";

const PostView = () => {
    const router = useRouter();
    const { postId } = router.query;

    const { data: fetchedPost, isLoading } = usePost(postId as string);

    if(isLoading || !fetchedPost) {
        return (
            <div className="flex justify-center items-center h-full">
                <ClipLoader />
            </div>
        )
    }

    return (
        <>
            <Header label="Post" showBackArrow />
            <PostItem data={fetchedPost} />
            <Form postId={postId as string} isComment placeholder="Post your reply"/>
            <CommentFeed comments={fetchedPost?.comments} />
        </>
    )
}

export default PostView

在Components/posts資料夾下,建立CommentFeed.tsx,負責建構評論列表。

import CommentItem from "./CommentItem";

interface CommentFeedProps {
	comments?: Record<string, any>[];
}

const CommentFeed: React.FC<CommentFeedProps> = ({ comments = [] }) => {
	return (
		<>
			{comments.map((comment) => (
				<CommentItem key={comment.id} data={comment} />
			))}
		</>
	);
};

export default CommentFeed;

在Components/posts資料夾下,建立CommentItem.tsx,用來呈現一則評論,包含了評論者的頭像、名字、用戶名、發布時間和內容。

import { formatDistanceToNowStrict } from "date-fns";
import { useRouter } from "next/router";
import { useCallback, useMemo } from "react";
import Avatar from "../Avatar";

interface CommentItemProps {
	data: Record<string, any>;
}

const CommentItem: React.FC<CommentItemProps> = ({ data }) => {
	const router = useRouter();
	const goToUser = useCallback(
		(event: any) => {
			event.stopPropagation();
			router.push(`/users/${data.user.id}`);
		},
		[router, data.user.id]
	);

    const createdAt = useMemo(() => {
        if(!data.createdAt){
            return null;
        }

        return formatDistanceToNowStrict(new Date(data.createdAt));
    }, [data?.createdAt]);

    return (
        <div className="border-b-[1px] border-neutral-800 p-5 cursor-pointer hover:bg-neutral-900 transition">
            <div className="flex flex-row items-start gap-3">
                <Avatar userId={data.user.id}/>
                <div>
                    <div className="flex flex-row items-center gap-2">
                        <p onClick={goToUser} className="text-white font-semibold cursor-pointer hover:underline">
                            {data.user.name}
                        </p>
                        <span className="text-neutral-500 cursor-pointer hover:underline hidden md:block">
                            @{data.user.username}
                        </span>
                        <span className="text-neutral-500 text-sm">
                            {createdAt}
                        </span>
                    </div>
                    <div className="text-white mt-1">
                        {data.content}
                    </div>
                </div>
            </div>
        </div>
    );
};

export default CommentItem;

現在啟動專案,進入其中一則貼文,發表評論後,會看到類似以下畫面的內容。

Untitled

我們的評論區塊已經完成了,接下來我們要開始著手開發通知功能。

修改SidebarItem.tsx,當有通知時會有藍點出現在通知標誌的右上角。

import { useCallback } from "react";
import { IconType } from "react-icons";
import { useRouter } from "next/router";
import useCurrentUser from "@/Hooks/useCurrentUser";
import useLoginModal from "@/Hooks/useLoginModal";
import { BsDot } from "react-icons/bs";

interface SidebarItemProps {
	label: string;
	href?: string;
	icon: IconType;
	onClick?: () => void;
	auth?: boolean;
	alert?: boolean;
}

const SidebarItem: React.FC<SidebarItemProps> = ({
	label,
	href,
	icon: Icon,
	onClick,
	auth,
	alert
}) => {
	const loginModal = useLoginModal();
	const { data: currentUser} = useCurrentUser();
	const router = useRouter();
	const handleClick = useCallback(() => {
		if(onClick){
			return onClick();
		}

		if(auth && !currentUser){
			loginModal.onOpen();
		}
		else if(href){
			router.push(href);
		}
	}, [router, onClick, href, currentUser, auth, loginModal]);

	return (
		<div onClick={handleClick} className="flex flex-row items-center">
			<div className="relative rounded-full h-14 w-14 flex items-center justify-center p-4 hover:bg-slate-300 cursor-pointer lg:hidden">
				<Icon size={28} color="white" />
				{alert ? <BsDot className="text-sky-500 absolute -top-4 left-0" size={70} /> : null}
			</div>
			<div className="relative hidden lg:flex items-center gap-4 p-4 rounded-full hover:bg-slate-300 hover:bg-opacity-10 cursor-pointer">
                <Icon size={24} color="white" />
                <p className="hidden lg:block text-white text-xl">{label}</p>
				{alert ? <BsDot className="text-sky-500 absolute -top-4 left-0" size={70} /> : null}
            </div>
		</div>
	);
};

export default SidebarItem;

修改Sidebar.tsx,通知區塊增加傳入的參數,決定是否顯示藍點。

import { BsHouseFill, BsBellFill } from "react-icons/bs";
import { FaUser } from "react-icons/fa";
import { BiLogOut } from "react-icons/bi";
import SidebarLogo from "./SidebarLogo";
import SidebarItem from "./SidebarItem";
import SidebarTweetButton from "./SidebarTweetButton";
import useCurrentUser from "@/Hooks/useCurrentUser";
import { signOut } from "next-auth/react";

const Sidebar = () => {
	const { data: currentUser } = useCurrentUser();
	const items = [
		{
			label: "Home",
			href: "/",
			icon: BsHouseFill,
		},
		{
			label: "Notifications",
			href: "/notifications",
			icon: BsBellFill,
			auth: true,
			alert: currentUser?.hasNotification
		},
		{
			label: "Profile",
			href: `/users/${currentUser?.id}`,
			icon: FaUser,
			auth: true
		},
	];
	return (
		<div className="col-span-1 h-full pr-4 md:pr-6">
			<div className="flex flex-col items-end">
				<div className="space-y-2 lg:w-[230px]">
					<SidebarLogo />
					{items.map((item) => (
						<SidebarItem
							key={item.href}
							href={item.href}
							label={item.label}
							icon={item.icon}
							auth={item.auth}
							alert={item.alert}
						/>
					))}
					{currentUser && (
						<SidebarItem onClick={() => signOut()} icon={BiLogOut} label="Logout" />
					)}
					<SidebarTweetButton />
				</div>
			</div>
		</div>
	);
};

export default Sidebar;

在api資料夾下,建立notifications資料夾,在notifications資料夾下,新增[userId].ts,根據用戶的 ID,從資料庫中查詢和回傳他們的通知,並將hasNotification設定成false,表示沒有新的通知。

import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@/libs/prismadb";

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
    ) {
    if(req.method !== 'GET'){
        return res.status(405).end();
    }

    try {
        const { userId } = req.query;
        
        if(!userId || typeof userId !== 'string'){
            throw new Error('Invalid ID');
        }

        const notification = await prisma.notification.findMany({
            where: {
                userId
            },
            orderBy: {
                createdAt: 'desc'
            }
        });

        await prisma.user.update({
            where: {
                id: userId
            },
            data: {
                hasNotification: false
            }
        });

        return res.status(200).json(notification);
    } catch (error) {
        console.log(error);
        return res.status(400).end();
    }
}

在Hooks資料夾下,新增useNotifications.ts,這個程式能夠透過API,取得指定的用戶ID的通知。

import useSWR from "swr";
import fetcher from "@/libs/fetcher";

const useNotifications = (userId?: string) => {
    const url = userId ? `/api/notifications/${userId}` : null;
    const { data, error, isLoading, mutate } = useSWR(url, fetcher);

  return {
    data,
    error,
    isLoading,
    mutate
  }
}

export default useNotifications

在pages下,新增notifications.tsx,用來呈現使用者收到的新通知。

import { NextPageContext } from 'next';
import { getSession } from "next-auth/react";
import Header from "@/Components/layout/Header";
import NotificationsFeed from "@/Components/NotificationsFeed";

export async function getServerSideProps(context: NextPageContext) {
    const session = await getSession(context);

    if(!session) {
        return {
            redirect: {
                destination: '/',
                permanent: false
            }
        }
    }

    return {
        props: {
            session
        }
    }
}

const Notifications = () => {
    return (
        <>
            <Header label="Notifications" showBackArrow/>
            <NotificationsFeed />
        </>
    )
}

export default Notifications;

在Components資料夾下,新增NotificationsFeed.tsx,用來設定通知頁面與通知的呈現格式。

import useCurrentUser from "@/Hooks/useCurrentUser";
import useNotifications from "@/Hooks/useNotifications";
import { useEffect } from "react";
import { BsTwitter } from "react-icons/bs";

const NotificationsFeed = () => {
	const { data: currentUser, mutate: mutateCurrentUser } = useCurrentUser();
	const { data: fetchedNotifications = [] } = useNotifications(currentUser?.id);

	useEffect(() => {
		mutateCurrentUser();
	}, [mutateCurrentUser]);

	if (fetchedNotifications.length === 0) {
		return (
			<div className="text-neutral-600 text-center p-6 text-xl">
				No notifications
			</div>
		);
	}

	return (
		<div className="flex flex-col">
			{fetchedNotifications.map((notification: Record<string, any>) => (
				<div
					key={notification.id}
					className="flex flex-row items-center p-6 gap-4 border-b-[1px] border-neutral-800"
				>
					<BsTwitter color="white" size={32} />
					<p className="text-white">{notification.content}</p>
				</div>
			))}
		</div>
	);
};

export default NotificationsFeed;

修改pages/api/like.ts,有人按下喜歡時就會傳送通知給發文的人。

import { NextApiRequest, NextApiResponse } from "next"; 
import serverAuth from "@/libs/serverAuth";
import prisma from "@/libs/prismadb";

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
    ){
    if(req.method !== 'POST' && req.method !== 'DELETE'){
        return res.status(405).end();
    }

    try{
        const { postId } = req.body;
        const { currentUser } = await serverAuth(req, res);

        if(!postId || typeof postId !== 'string'){
            throw new Error('Invalid ID');
        }

        const post = await prisma.post.findUnique({
            where: {
                id: postId
            }
        });

        if(!post){
            throw new Error('Invalid ID');
        }

        let updatedLikedIds = [ ...(post.likedIds || []) ];

        if(req.method === 'POST'){
            updatedLikedIds.push(currentUser.id);

            try{
                const post = await prisma.post.findUnique({
                    where: {
                        id: postId
                    }
                });

                if(post?.userId){
                    await prisma.notification.create({
                        data: {
                            content: "Someone liked your post!",
                            userId: post.userId
                        }
                    });

                    await prisma.user.update({
                        where: {
                            id: post.userId
                        },
                        data: {
                            hasNotification: true
                        }
                    })
                }
            } catch(error){
                console.log(error);
            }
        }
        if(req.method === 'DELETE'){
            updatedLikedIds = updatedLikedIds.filter((likedId) => likedId !== currentUser.id);
        }

        const updatedPost = await prisma.post.update({
            where: {
                id: postId
            },
            data: {
                likedIds: updatedLikedIds
            }
        });

        return res.status(200).json(updatedPost);

    } catch(error){
        console.log(error);
        return res.status(400).end();
    }
}

修改pages/api/commments.ts,有人按下留言就會發送通知。

import { NextApiRequest, NextApiResponse } from "next";
import serverAuth from "@/libs/serverAuth";
import prisma from "@/libs/prismadb";

export default async function handler(
	req: NextApiRequest,
	res: NextApiResponse
) {
	if (req.method !== "POST") {
		return res.status(405).end();
	}

	try {
        const { currentUser } = await serverAuth(req, res);
        const { content } = req.body;
        const { postId } = req.query;

        if(!postId || typeof postId !== 'string'){
            throw new Error('Invalid ID');
        }

        const commment = await prisma.comment.create({
            data: {
                content,
                userId: currentUser.id,
                postId
            }
        });

        try{
            const post = await prisma.post.findUnique({
                where: {
                    id: postId
                }
            });

            if(post?.userId){
                await prisma.notification.create({
                    data: {
                        content: "Someone replied your post!",
                        userId: post.userId
                    }
                });

                await prisma.user.update({
                    where: {
                        id: post.userId
                    },
                    data: {
                        hasNotification: true
                    }
                })
            }
        } catch(error){
            console.log(error);
        }

        return res.status(200).json(commment);
	} catch (error) {
		console.log(error);
		return res.status(400).end();
	}
}

修改pages/api/follow.ts,有人按下追蹤就會發送通知。

import { NextApiRequest, NextApiResponse } from "next";
import serverAuth from "@/libs/serverAuth";
import prisma from "@/libs/prismadb";

export default async function handler(
	req: NextApiRequest,
	res: NextApiResponse
) {
	if (req.method !== "POST" && req.method !== "DELETE") {
		return res.status(405).end();
	}

	try {
		const { userId } = req.body;
		const { currentUser } = await serverAuth(req, res);

		if (!userId || typeof userId !== "string") {
			throw new Error("Invalid ID");
		}

		const user = await prisma.user.findUnique({
			where: {
				id: userId,
			},
		});

		if (!user) {
			throw new Error("Invalid ID");
		}

		let updatedFollowingIds = [ ...(user.followingIds || []) ];

		if (req.method === "POST") {
			updatedFollowingIds.push(userId);

			try{
				await prisma.notification.create({
					data: {
						content: "Someone followed you",
						userId
					}
				});

				await prisma.user.update({
					where: {
						id: userId
					},
					data: {
						hasNotification: true
					}
				})
			} catch(error){
				console.log(error);
			}
		}

		if (req.method === "DELETE") {
			updatedFollowingIds = updatedFollowingIds.filter(
				(followingId) => followingId !== userId
			);
		}

		const updateUser = await prisma.user.update({
			where: {
				id: currentUser.id,
			},
			data: {
				followingIds: updatedFollowingIds,
			},
		});

		return res.status(200).json(updateUser);
	} catch (error) {
		console.log(error);
		return res.status(400).end();
	}
}

我們自製的X專案就完成了。


上一篇
【Day26】模仿知名網站的外觀 X(13) 完成追隨和喜歡功能
下一篇
【Day28】Github介紹與使用
系列文
模仿知名網站的外觀30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言