diff --git a/app/providers.tsx b/app/providers.tsx index 5232990..dfceab7 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -3,8 +3,8 @@ import { theme } from "@/chakra/theme"; import Layout from "@/components/layout/Layout"; import { ColorModeProvider } from "@/components/ui/color-mode"; -import { toaster } from "@/hooks/useCustomToast"; -import { ChakraProvider, Toaster } from "@chakra-ui/react"; +import { Toaster } from "@/components/ui/toaster"; +import { ChakraProvider } from "@chakra-ui/react"; import { Provider as JotaiProvider } from "jotai"; import { useEffect, useState } from "react"; import EmotionRegistry from "./emotion-registry"; @@ -29,46 +29,7 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} - {mounted && ( - // @ts-ignore - - {(toast: any) => { - const type = toast.type || "info"; - const colors: any = { - success: "green", - error: "red", - warning: "orange", - info: "blue", - }; - const colorScheme = colors[type]; - - return ( - - - {toast.title} - - {toast.description && ( - - {toast.description} - - )} - - ); - }} - - )} + {mounted && } diff --git a/components/community/community-header/CommunityHeader.tsx b/components/community/community-header/CommunityHeader.tsx index 0764027..49edeff 100644 --- a/components/community/community-header/CommunityHeader.tsx +++ b/components/community/community-header/CommunityHeader.tsx @@ -1,6 +1,6 @@ import { Community } from "@/types/community"; import { Box, Flex } from "@chakra-ui/react"; -import React from "react"; +import React, { useState } from "react"; import useCommunityState from "@/hooks/community/useCommunityState"; import useCommunityMembershipActions from "@/hooks/community/useCommunityMembershipActions"; import CommunityIcon from "./CommunityIcon"; @@ -8,6 +8,7 @@ import CommunityName from "./CommunityName"; import JoinOrLeaveButton from "./JoinOrLeaveButton"; import CommunitySettings from "./CommunitySettings"; import CommunityMembersButton from "./CommunityMembersButton"; +import ConfirmationDialog from "@/components/modal/ConfirmationDialog"; /** * @param {communityData} - data required to be displayed @@ -36,6 +37,21 @@ const CommunityHeader: React.FC = ({ communityData }) => { const isJoined = !!communityStateValue.mySnippets.find( (item) => item.communityId === communityData.id ); + const [leaveConfirmationOpen, setLeaveConfirmationOpen] = useState(false); + + const handleJoinOrLeave = () => { + if (isJoined) { + setLeaveConfirmationOpen(true); + } else { + onJoinOrLeaveCommunity(communityData, isJoined); + } + }; + + const onConfirmLeave = async () => { + await onJoinOrLeaveCommunity(communityData, isJoined); + setLeaveConfirmationOpen(false); + }; + return ( @@ -66,12 +82,21 @@ const CommunityHeader: React.FC = ({ communityData }) => { onJoinOrLeaveCommunity(communityData, isJoined)} + onClick={handleJoinOrLeave} /> + setLeaveConfirmationOpen(false)} + onConfirm={onConfirmLeave} + title="Unsubscribe from Community" + body={`Are you sure you want to unsubscribe from r/${communityData.id}?`} + confirmButtonText="Unsubscribe" + isLoading={loading} + /> ); }; diff --git a/components/modal/ConfirmationDialog.tsx b/components/modal/ConfirmationDialog.tsx new file mode 100644 index 0000000..498b219 --- /dev/null +++ b/components/modal/ConfirmationDialog.tsx @@ -0,0 +1,89 @@ +import { + Button, + DialogActionTrigger, + DialogBackdrop, + DialogBody, + DialogCloseTrigger, + DialogContent, + DialogFooter, + DialogHeader, + DialogPositioner, + DialogRoot, + DialogTitle, + Portal, +} from "@chakra-ui/react"; +import React from "react"; + +interface ConfirmationDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + body: string; + confirmButtonText?: string; + cancelButtonText?: string; + isLoading?: boolean; +} + +const ConfirmationDialog: React.FC = ({ + open, + onClose, + onConfirm, + title, + body, + confirmButtonText = "Confirm", + cancelButtonText = "Cancel", + isLoading = false, +}) => { + return ( + !details.open && onClose()} + placement="center" + > + + + + + + {title} + + {body} + + + { + e.stopPropagation(); + onClose(); + }} + disabled={isLoading} + > + {cancelButtonText} + + + { + e.stopPropagation(); + onConfirm(); + }} + loading={isLoading} + > + {confirmButtonText} + + + { + e.stopPropagation(); + onClose(); + }} + /> + + + + + ); +}; + +export default ConfirmationDialog; diff --git a/components/modal/auth/Login.tsx b/components/modal/auth/Login.tsx index 5282486..0c93424 100644 --- a/components/modal/auth/Login.tsx +++ b/components/modal/auth/Login.tsx @@ -6,6 +6,7 @@ import { authModalStateAtom } from "../../../atoms/authModalAtom"; import { auth } from "../../../firebase/clientApp"; import { FIREBASE_ERRORS } from "../../../firebase/errors"; import InputField from "./InputField"; +import { PasswordInput } from "@/components/ui/password-input"; type LoginProps = {}; @@ -78,11 +79,27 @@ const Login: React.FC = () => { onChange={onChange} /> - { onChange={onChange} /> - - {/* If there is error than the error is shown */} diff --git a/components/modal/community-members/CommunityMembersModal.tsx b/components/modal/community-members/CommunityMembersModal.tsx index 5fbe8c9..80e0a15 100644 --- a/components/modal/community-members/CommunityMembersModal.tsx +++ b/components/modal/community-members/CommunityMembersModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { Box, DialogBackdrop, @@ -25,6 +25,7 @@ import { useAtomValue } from "jotai"; import { communityStateAtom } from "@/atoms/communitiesAtom"; import useCommunityPermissions from "@/hooks/community/useCommunityPermissions"; import useRemoveCommunityMember from "@/hooks/community/useRemoveCommunityMember"; +import ConfirmationDialog from "@/components/modal/ConfirmationDialog"; type CommunityMembersModalProps = { isOpen: boolean; @@ -43,6 +44,7 @@ const CommunityMembersModal: React.FC = ({ communityStateValue.currentCommunity ); const { removeMember, loading: removeLoading } = useRemoveCommunityMember(); + const [memberToRemove, setMemberToRemove] = useState(null); useEffect(() => { if (!isOpen) return; @@ -56,6 +58,12 @@ const CommunityMembersModal: React.FC = ({ } }; + const confirmRemoveMember = async () => { + if (!memberToRemove) return; + await handleRemoveMember(memberToRemove); + setMemberToRemove(null); + }; + const renderContent = () => { if (loading) { return ( @@ -98,12 +106,10 @@ const CommunityMembersModal: React.FC = ({ {isAdmin && ( handleRemoveMember(member.uid)} - disabled={removeLoading} - aria-label="Remove member" + colorPalette="red" size="sm" + aria-label="Remove member" + onClick={() => setMemberToRemove(member.uid)} > @@ -117,24 +123,28 @@ const CommunityMembersModal: React.FC = ({ return ( { - if (!open) onClose(); - }} + onOpenChange={(details: { open: boolean }) => !details.open && onClose()} + placement="center" > - - - - - - {`${members.length} Subscribers`} - - - - {renderContent()} - - - - + + + + + Community Members + + + {renderContent()} + + + setMemberToRemove(null)} + onConfirm={confirmRemoveMember} + title="Remove Member" + body="Are you sure you want to remove this member from the community?" + confirmButtonText="Remove" + isLoading={removeLoading} + /> ); }; diff --git a/components/modal/community-settings/AdminManager.tsx b/components/modal/community-settings/AdminManager.tsx index 0906166..543b8bd 100644 --- a/components/modal/community-settings/AdminManager.tsx +++ b/components/modal/community-settings/AdminManager.tsx @@ -17,6 +17,7 @@ import { import React, { useEffect, useState } from "react"; import { useAuthState } from "react-firebase-hooks/auth"; import { AdminUser } from "@/types/adminUser"; +import ConfirmationDialog from "@/components/modal/ConfirmationDialog"; type AdminManagerProps = { communityData: Community; @@ -33,6 +34,8 @@ const AdminManager: React.FC = ({ communityData }) => { const [showResults, setShowResults] = useState(false); const showToast = useCustomToast(); const [user] = useAuthState(auth); + const [adminToRemove, setAdminToRemove] = useState(null); + const [removingAdmin, setRemovingAdmin] = useState(false); useEffect(() => { loadAdmins(communityData.creatorId, communityData.adminIds).catch( @@ -101,10 +104,12 @@ const AdminManager: React.FC = ({ communityData }) => { } }; - const onRemoveAdmin = async (uid: string) => { + const confirmRemoveAdmin = async () => { + if (!adminToRemove) return; + setRemovingAdmin(true); try { // Remove admin (updates Firestore + local + global state) - await handleRemoveAdmin(communityData.id, uid, setAdmins); + await handleRemoveAdmin(communityData.id, adminToRemove, setAdmins); showToast({ title: "Admin removed", @@ -118,6 +123,9 @@ const AdminManager: React.FC = ({ communityData }) => { description: "Could not remove admin", status: "error", }); + } finally { + setRemovingAdmin(false); + setAdminToRemove(null); } }; @@ -239,7 +247,7 @@ const AdminManager: React.FC = ({ communityData }) => { size="sm" variant="outline" colorPalette="red" - onClick={() => onRemoveAdmin(admin.uid)} + onClick={() => setAdminToRemove(admin.uid)} > Remove @@ -253,6 +261,15 @@ const AdminManager: React.FC = ({ communityData }) => { ))} )} + setAdminToRemove(null)} + onConfirm={confirmRemoveAdmin} + title="Remove Admin" + body="Are you sure you want to remove this user from admins?" + confirmButtonText="Remove" + isLoading={removingAdmin} + /> ); }; diff --git a/components/modal/community-settings/CommunitySettings.tsx b/components/modal/community-settings/CommunitySettings.tsx index aebc1a7..f6ee9d3 100644 --- a/components/modal/community-settings/CommunitySettings.tsx +++ b/components/modal/community-settings/CommunitySettings.tsx @@ -15,8 +15,7 @@ import { DialogRoot, DialogTitle, Portal, - Separator, - Stack, + Tabs, } from "@chakra-ui/react"; import { useAtom } from "jotai"; import React, { useRef, useState } from "react"; @@ -114,37 +113,48 @@ const CommunitySettingsModal: React.FC = ({ flexDirection="column" padding="10px 0px" > - - - - - - - - - + + + Profile + Privacy + Admins + Danger Zone + + + + + + + + + + + + + + diff --git a/components/modal/community-settings/DangerZone.tsx b/components/modal/community-settings/DangerZone.tsx index deb3ddf..68d7e34 100644 --- a/components/modal/community-settings/DangerZone.tsx +++ b/components/modal/community-settings/DangerZone.tsx @@ -1,5 +1,6 @@ import { Button, Stack, Text } from "@chakra-ui/react"; -import React from "react"; +import React, { useState } from "react"; +import ConfirmationDialog from "@/components/modal/ConfirmationDialog"; type DangerZoneProps = { deleteCommunity: () => Promise; @@ -13,6 +14,8 @@ const DangerZone: React.FC = ({ deleteCommunity, loading, }) => { + const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); + return ( @@ -23,13 +26,25 @@ const DangerZone: React.FC = ({ setDeleteConfirmationOpen(true)} loading={loading} > Delete Community + setDeleteConfirmationOpen(false)} + onConfirm={() => { + deleteCommunity(); + setDeleteConfirmationOpen(false); + }} + title="Delete Community" + body="Are you sure you want to delete this community? This action cannot be undone and will delete all posts and comments." + confirmButtonText="Delete Community" + isLoading={loading} + /> ); }; diff --git a/components/modal/community-settings/PrivacySettings.tsx b/components/modal/community-settings/PrivacySettings.tsx index f5497d8..dfc79ae 100644 --- a/components/modal/community-settings/PrivacySettings.tsx +++ b/components/modal/community-settings/PrivacySettings.tsx @@ -52,7 +52,7 @@ const PrivacySettings: React.FC = ({ p={3} border="1px solid" borderColor={isSelected ? "red.500" : "gray.200"} - borderRadius="md" + borderRadius="xl" cursor="pointer" onClick={() => handlePrivacyTypeChange({ value: type.value })} bg={ diff --git a/components/modal/create-community/CommunityTypeOption.tsx b/components/modal/create-community/CommunityTypeOption.tsx index a173a3e..d3003be 100644 --- a/components/modal/create-community/CommunityTypeOption.tsx +++ b/components/modal/create-community/CommunityTypeOption.tsx @@ -1,11 +1,5 @@ import React, { FC } from "react"; -import { Icon, Text, Flex } from "@chakra-ui/react"; -import { - CheckboxControl, - CheckboxIndicator, - CheckboxLabel, - CheckboxRoot, -} from "@chakra-ui/react"; +import { CheckboxCard, Icon, Flex, VStack } from "@chakra-ui/react"; import type { IconType } from "react-icons"; type CommunityTypeOptionProps = { @@ -26,40 +20,34 @@ const CommunityTypeOption: FC = ({ onChange, }) => { return ( - onChange(name)} colorPalette="red" - display="flex" - alignItems="center" - gap={2} + borderRadius="xl" cursor="pointer" - py={1} > - - - - - - - - {label} - - - {description} - - - - + + + + + + + {label} + + {description} + + + + + + + ); }; diff --git a/components/modal/create-community/CommunityTypeOptions.tsx b/components/modal/create-community/CommunityTypeOptions.tsx index 6657021..120cf20 100644 --- a/components/modal/create-community/CommunityTypeOptions.tsx +++ b/components/modal/create-community/CommunityTypeOptions.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { VStack } from "@chakra-ui/react"; import CommunityTypeOption from "./CommunityTypeOption"; interface CommunityTypeOptionsProps { @@ -18,7 +19,8 @@ const CommunityTypeOptions: React.FC = ({ onCommunityTypeChange, }) => { return ( - + // add top margin to increase gap between the title and options + {options.map((option) => ( = ({ onChange={onCommunityTypeChange} /> ))} - + ); }; diff --git a/components/posts/comments/CommentItem.tsx b/components/posts/comments/CommentItem.tsx index 0210656..37dbb0b 100644 --- a/components/posts/comments/CommentItem.tsx +++ b/components/posts/comments/CommentItem.tsx @@ -13,6 +13,7 @@ import React, { useState } from "react"; import { CgProfile } from "react-icons/cg"; import { LuPencil, LuReply, LuTrash } from "react-icons/lu"; import CommentInput from "./CommentInput"; +import ConfirmationDialog from "@/components/modal/ConfirmationDialog"; /** * Required props for CommentItem component @@ -30,7 +31,8 @@ type CommentItemProps = { onCreateComment: ( user: User, text: string, - parentId: string + parentId: string, + depth: number ) => Promise; user?: User; }; @@ -62,11 +64,17 @@ const CommentItem: React.FC = ({ const [isReplying, setIsReplying] = useState(false); const [replyText, setReplyText] = useState(""); const [replyLoading, setReplyLoading] = useState(false); + const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const handleReply = async () => { if (!user) return; setReplyLoading(true); - await onCreateComment(user, replyText, comment.id); + await onCreateComment( + user, + replyText, + comment.id, + (comment.depth || 0) + 1 + ); setReplyLoading(false); setReplyText(""); setIsReplying(false); @@ -103,21 +111,23 @@ const CommentItem: React.FC = ({ borderColor={{ base: "gray.100", _dark: "gray.600" }} > - setIsReplying(!isReplying)} - > - - Reply - + {(comment.depth || 0) < 2 && ( + setIsReplying(!isReplying)} + > + + Reply + + )} {userId === comment.creatorId && ( = ({ color: { base: "red.500", _dark: "red.400" }, bg: "transparent", }} - onClick={() => onDeleteComment(comment)} + onClick={() => setDeleteConfirmationOpen(true)} > Delete @@ -171,6 +181,18 @@ const CommentItem: React.FC = ({ /> )} + setDeleteConfirmationOpen(false)} + onConfirm={() => { + onDeleteComment(comment); + setDeleteConfirmationOpen(false); + }} + title="Delete Comment" + body="Are you sure you want to delete this comment? This action cannot be undone." + confirmButtonText="Delete" + isLoading={loadingDelete} + /> ); }; diff --git a/components/posts/new-post-form/NewPostForm.tsx b/components/posts/new-post-form/NewPostForm.tsx index 036a65c..468cbd7 100644 --- a/components/posts/new-post-form/NewPostForm.tsx +++ b/components/posts/new-post-form/NewPostForm.tsx @@ -1,15 +1,15 @@ import { Community } from "@/types/community"; import useCreatePost from "@/hooks/posts/useCreatePost"; import useSelectFile from "@/hooks/useSelectFile"; -import { Flex, Icon } from "@chakra-ui/react"; +import { Flex, Icon, Tabs, Text } from "@chakra-ui/react"; import { User } from "firebase/auth"; import { useParams, useRouter } from "next/navigation"; import React, { useState } from "react"; import { IoDocumentText, IoImageOutline } from "react-icons/io5"; import BackToCommunityButton from "./BackToCommunityButton"; -import PostBody from "./PostBody"; import PostCreateError from "./PostCreateError"; -import TabList from "./TabList"; +import TextInputs from "../post-form/TextInputs"; +import ImageUpload from "../post-form/ImageUpload"; /** * Props for NewPostForm component. @@ -121,23 +121,66 @@ const NewPostForm: React.FC = ({ mt={2} shadow="md" > - - - + setSelectedTab(e.value)} + width="100%" + variant="plain" + > + + {formTabs.map((item) => ( + + + + + {item.title} + + ))} + + + + + + + + + ); diff --git a/components/posts/new-post-form/PostBody.tsx b/components/posts/new-post-form/PostBody.tsx deleted file mode 100644 index 9c6cc1b..0000000 --- a/components/posts/new-post-form/PostBody.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from "react"; -import { Flex } from "@chakra-ui/react"; -import TextInputs from "../post-form/TextInputs"; -import ImageUpload from "../post-form/ImageUpload"; - -type PostBodyProps = { - selectedTab: string; - handleCreatePost: () => Promise; - onTextChange: ( - event: React.ChangeEvent - ) => void; - loading: boolean; - textInputs: { title: string; body: string }; - selectedFile: string | undefined; - onSelectFile: (event: React.ChangeEvent) => void; - setSelectedTab: React.Dispatch>; - setSelectedFile: React.Dispatch>; -}; - -const PostBody: React.FC = ({ - selectedTab, - handleCreatePost, - onTextChange, - loading, - textInputs, - selectedFile, - onSelectFile, - setSelectedTab, - setSelectedFile, -}) => { - return ( - - {selectedTab === "Post" && ( - - )} - - {selectedTab === "Images" && ( - - )} - - ); -}; - -export default PostBody; diff --git a/components/posts/new-post-form/TabItem.tsx b/components/posts/new-post-form/TabItem.tsx deleted file mode 100644 index aa364bf..0000000 --- a/components/posts/new-post-form/TabItem.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Flex, Icon, Text } from "@chakra-ui/react"; -import React from "react"; -import { FormTab } from "./NewPostForm"; - -/** - * TabItem component for displaying tabs at the top of the NewPostForm. - * @param {FormTab} item - FormTab object from NewPostForm - * @param {boolean} selected - whether the tab is selected or not - * @param {function} setSelectedTab - selecting a tab - */ -type TabItemProps = { - item: FormTab; - selected: boolean; - setSelectedTab: (value: string) => void; -}; - -/** - * Displays tab buttons at the top of the NewPostForm. - * Allows the user to select a tab to display the form for that tab. - * @param {FormTab} item - FormTab object from NewPostForm - * @param {boolean} selected - whether the tab is selected or not - * @param {() => {}} setSelectedTab - selecting a tab - * - * @returns {React.FC} - TabItem component for NewPostForm - */ -const TabItem: React.FC = ({ - item, - selected, - setSelectedTab, -}) => { - return ( - setSelectedTab(item.title)} - shadow="md" - > - - - - {item.title} - - ); -}; -export default TabItem; diff --git a/components/posts/new-post-form/TabList.tsx b/components/posts/new-post-form/TabList.tsx deleted file mode 100644 index e772e11..0000000 --- a/components/posts/new-post-form/TabList.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import { Stack } from "@chakra-ui/react"; -import TabItem from "./TabItem"; - -type FormTabShape = { title: string; icon: any }; - -type TabListProps = { - formTabs: FormTabShape[]; - selectedTab: string; - setSelectedTab: React.Dispatch>; -}; - -const TabList: React.FC = ({ - formTabs, - selectedTab, - setSelectedTab, -}) => { - return ( - - {formTabs.map((item) => ( - - ))} - - ); -}; - -export default TabList; diff --git a/components/posts/post-item/PostItem.tsx b/components/posts/post-item/PostItem.tsx index fa642c1..48fa319 100644 --- a/components/posts/post-item/PostItem.tsx +++ b/components/posts/post-item/PostItem.tsx @@ -10,6 +10,7 @@ import PostDetails from "./PostDetails"; import PostTitle from "./PostTitle"; import PostBody from "./PostBody"; import PostActions from "./PostActions"; +import ConfirmationDialog from "@/components/modal/ConfirmationDialog"; type PostItemProps = { post: Post; @@ -52,6 +53,7 @@ const PostItem: React.FC = ({ const [loadingImage, setLoadingImage] = useState(true); const [error, setError] = useState(false); const [loadingDelete, setLoadingDelete] = useState(false); + const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const router = useRouter(); const showToast = useCustomToast(); const { onSavePost, isPostSaved } = useSavedPosts(); @@ -59,10 +61,14 @@ const PostItem: React.FC = ({ const singlePostPage = !onSelectPost; - const handleDelete = async ( + const handleDeleteClick = async ( event: React.MouseEvent ) => { - event.stopPropagation(); // stop event bubbling up to parent + event.stopPropagation(); + setDeleteConfirmationOpen(true); + }; + + const onConfirmDelete = async () => { setLoadingDelete(true); try { const success: boolean = await onDeletePost(post); // call the delete function from usePosts hook @@ -80,7 +86,11 @@ const PostItem: React.FC = ({ // if the user deletes post from the single post page, they should be redirected to the post's community page if (singlePostPage) { // if the post is on the single post page - router.push(`/community/${post.communityId}`); // redirect to the community page + if (post.communityId) { + router.push(`/community/${post.communityId}`); // redirect to the community page + } else { + router.push("/"); // redirect to home if communityId is missing + } } } catch (error: any) { setError(error.message); @@ -91,6 +101,7 @@ const PostItem: React.FC = ({ }); } finally { setLoadingDelete(false); + setDeleteConfirmationOpen(false); } }; @@ -150,7 +161,7 @@ const PostItem: React.FC = ({ /> = ({ error={error} message={"There was an error when loading this post"} /> + setDeleteConfirmationOpen(false)} + onConfirm={onConfirmDelete} + title="Delete Post" + body="Are you sure you want to delete this post? This action cannot be undone." + confirmButtonText="Delete" + isLoading={loadingDelete} + /> ); diff --git a/components/ui/password-input.tsx b/components/ui/password-input.tsx new file mode 100644 index 0000000..3b9af80 --- /dev/null +++ b/components/ui/password-input.tsx @@ -0,0 +1,159 @@ +"use client" + +import type { + ButtonProps, + GroupProps, + InputProps, + StackProps, +} from "@chakra-ui/react" +import { + Box, + HStack, + IconButton, + Input, + InputGroup, + Stack, + mergeRefs, + useControllableState, +} from "@chakra-ui/react" +import * as React from "react" +import { LuEye, LuEyeOff } from "react-icons/lu" + +export interface PasswordVisibilityProps { + /** + * The default visibility state of the password input. + */ + defaultVisible?: boolean + /** + * The controlled visibility state of the password input. + */ + visible?: boolean + /** + * Callback invoked when the visibility state changes. + */ + onVisibleChange?: (visible: boolean) => void + /** + * Custom icons for the visibility toggle button. + */ + visibilityIcon?: { on: React.ReactNode; off: React.ReactNode } +} + +export interface PasswordInputProps + extends InputProps, + PasswordVisibilityProps { + rootProps?: GroupProps +} + +export const PasswordInput = React.forwardRef< + HTMLInputElement, + PasswordInputProps +>(function PasswordInput(props, ref) { + const { + rootProps, + defaultVisible, + visible: visibleProp, + onVisibleChange, + visibilityIcon = { on: , off: }, + ...rest + } = props + + const [visible, setVisible] = useControllableState({ + value: visibleProp, + defaultValue: defaultVisible || false, + onChange: onVisibleChange, + }) + + const inputRef = React.useRef(null) + + return ( + { + if (rest.disabled) return + if (e.button !== 0) return + e.preventDefault() + setVisible(!visible) + }} + > + {visible ? visibilityIcon.off : visibilityIcon.on} + + } + {...rootProps} + > + + + ) +}) + +const VisibilityTrigger = React.forwardRef( + function VisibilityTrigger(props, ref) { + return ( + + ) + }, +) + +interface PasswordStrengthMeterProps extends StackProps { + max?: number + value: number +} + +export const PasswordStrengthMeter = React.forwardRef< + HTMLDivElement, + PasswordStrengthMeterProps +>(function PasswordStrengthMeter(props, ref) { + const { max = 4, value, ...rest } = props + + const percent = (value / max) * 100 + const { label, colorPalette } = getColorPalette(percent) + + return ( + + + {Array.from({ length: max }).map((_, index) => ( + + ))} + + {label && {label}} + + ) +}) + +function getColorPalette(percent: number) { + switch (true) { + case percent < 33: + return { label: "Low", colorPalette: "red" } + case percent < 66: + return { label: "Medium", colorPalette: "orange" } + default: + return { label: "High", colorPalette: "green" } + } +} diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx new file mode 100644 index 0000000..ae9e74a --- /dev/null +++ b/components/ui/toaster.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { + Toaster as ChakraToaster, + Portal, + Spinner, + Stack, + Toast, + createToaster, +} from "@chakra-ui/react"; + +export const toaster = createToaster({ + placement: "bottom-end", + pauseOnPageIdle: true, +}); + +export const Toaster = () => { + return ( + + + {(toast: any) => ( + + {toast.type === "loading" ? ( + + ) : ( + + )} + + {toast.title && {toast.title}} + {toast.description && ( + {toast.description} + )} + + {toast.action && ( + {toast.action.label} + )} + {toast.closable && } + + )} + + + ); +}; diff --git a/hooks/comments/useCreateComment.ts b/hooks/comments/useCreateComment.ts index 15d4c5b..f4a4a8a 100644 --- a/hooks/comments/useCreateComment.ts +++ b/hooks/comments/useCreateComment.ts @@ -25,7 +25,8 @@ const useCreateComment = ( const onCreateComment = async ( user: User, commentText: string, - parentId?: string + parentId?: string, + depth: number = 0 ) => { if (!selectedPost) return; setCreateLoading(true); @@ -36,6 +37,7 @@ const useCreateComment = ( selectedPost.id!, selectedPost.title, commentText, + depth, parentId ); @@ -51,7 +53,8 @@ const useCreateComment = ( console.log("onCreateComment error", error); showToast({ title: "Comment failed", - description: "There was an error creating your comment", + description: + error.message || "There was an error creating your comment", status: "error", }); } finally { diff --git a/hooks/useCustomToast.ts b/hooks/useCustomToast.ts index 405a3dd..3c1f07b 100644 --- a/hooks/useCustomToast.ts +++ b/hooks/useCustomToast.ts @@ -1,4 +1,4 @@ -import { createToaster } from "@chakra-ui/react"; +import { toaster } from "@/components/ui/toaster"; import { useCallback } from "react"; interface CustomToastOptions { @@ -7,11 +7,6 @@ interface CustomToastOptions { status: "success" | "error" | "warning" | "info"; } -export const toaster = createToaster({ - placement: "top", - gap: 16, -}); - /** * Returns a memoized helper to show consistent Chakra toasts. * @param title - Heading shown in the toast. diff --git a/lib/comments/createComment.ts b/lib/comments/createComment.ts index 1951d1e..a70bd13 100644 --- a/lib/comments/createComment.ts +++ b/lib/comments/createComment.ts @@ -16,8 +16,15 @@ export const createComment = async ( postId: string, postTitle: string, commentText: string, + depth: number, parentId?: string ) => { + if (depth > 2) { + throw new Error( + "Maximum comment depth reached. You cannot reply to this comment." + ); + } + const batch = writeBatch(firestore); const commentDocRef = doc(collection(firestore, "comments")); const newComment: Comment = { @@ -30,6 +37,7 @@ export const createComment = async ( text: commentText, createdAt: serverTimestamp() as Timestamp, parentId: parentId || undefined, + depth: depth, }; if (!parentId) delete newComment.parentId; diff --git a/package.json b/package.json index 14844ec..4d185a4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-firebase-hooks": "^5.1.1", - "react-icons": "^5.2.1", + "react-icons": "^5.5.0", "safe-json-stringify": "^1.2.0", "typescript": "5.4.5" }, @@ -42,4 +42,4 @@ "baseline-browser-mapping": "^2.9.3", "eslint": "^9" } -} \ No newline at end of file +} diff --git a/types/comment.ts b/types/comment.ts index 0176ae8..aae4c64 100644 --- a/types/comment.ts +++ b/types/comment.ts @@ -13,4 +13,5 @@ export type Comment = { text: string; createdAt: Timestamp; parentId?: string; + depth: number; }; diff --git a/yarn.lock b/yarn.lock index e437eb7..7c04e58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5009,7 +5009,7 @@ react-firebase-hooks@^5.1.1: resolved "https://registry.yarnpkg.com/react-firebase-hooks/-/react-firebase-hooks-5.1.1.tgz#fc92bb4b860c6753c806583f64d7f069b6ee6785" integrity sha512-y2UpWs82xs+39q5Rc/wq316ca52QsC0n8m801V+yM4IC4hbfOL4yQPVSh7w+ydstdvjN9F+lvs1WrO2VYxpmdA== -react-icons@^5.2.1: +react-icons@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.5.0.tgz#8aa25d3543ff84231685d3331164c00299cdfaf2" integrity sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==