diff --git a/.env.example b/.env.example index 70790aa9..a4de9d6c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +APP_URL=http://localhost +APP_SECRET= + PORT=3000 DEBUG_MODE=true NODE_ENV=production diff --git a/apps/client/package.json b/apps/client/package.json index 9a7fb440..5172b2ab 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -26,6 +26,7 @@ "jotai": "^2.7.2", "jotai-optics": "^0.3.2", "js-cookie": "^3.0.5", + "jwt-decode": "^4.0.0", "react": "^18.2.0", "react-arborist": "^3.4.0", "react-dom": "^18.2.0", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index b56cf716..3b6bd983 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -22,6 +22,7 @@ import { io } from "socket.io-client"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts"; import { SOCKET_URL } from "@/features/websocket/types"; import AccountPreferences from "@/pages/settings/account/account-preferences.tsx"; +import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx"; export default function App() { const [, setSocket] = useAtom(socketAtom); @@ -60,6 +61,7 @@ export default function App() { } /> } /> } /> + } /> }> } /> diff --git a/apps/client/src/features/auth/components/auth.module.css b/apps/client/src/features/auth/components/auth.module.css index 6df26d9e..83626362 100644 --- a/apps/client/src/features/auth/components/auth.module.css +++ b/apps/client/src/features/auth/components/auth.module.css @@ -1,6 +1,12 @@ .authBackground { - position: relative; - min-height: 100vh; - background-size: cover; - background-image: url(https://images.unsplash.com/photo-1701010063921-5f3255259e6d?q=80&w=3024&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D); + position: relative; + min-height: 100vh; + background-size: cover; + background-image: url(https://images.unsplash.com/photo-1701010063921-5f3255259e6d?q=80&w=3024&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D); +} + +.container { + box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px; + border-radius: 4px; + background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1)); } diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx new file mode 100644 index 00000000..c21e586d --- /dev/null +++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx @@ -0,0 +1,102 @@ +import * as React from "react"; +import * as z from "zod"; + +import { useForm, zodResolver } from "@mantine/form"; +import { + Container, + Title, + TextInput, + Button, + PasswordInput, + Box, + Stack, +} from "@mantine/core"; +import { useParams, useSearchParams } from "react-router-dom"; +import { IRegister } from "@/features/auth/types/auth.types"; +import useAuth from "@/features/auth/hooks/use-auth"; +import classes from "@/features/auth/components/auth.module.css"; +import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts"; +import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; + +const formSchema = z.object({ + name: z.string().min(2), + password: z.string().min(8), +}); + +type FormValues = z.infer; + +export function InviteSignUpForm() { + const params = useParams(); + const [searchParams] = useSearchParams(); + + const { data: invitation } = useGetInvitationQuery(params?.invitationId); + const { invitationSignup, isLoading } = useAuth(); + useRedirectIfAuthenticated(); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + name: "", + password: "", + }, + }); + + async function onSubmit(data: IRegister) { + const invitationToken = searchParams.get("token"); + + await invitationSignup({ + invitationId: invitation.id, + name: data.name, + password: data.password, + token: invitationToken, + }); + } + + if (!invitation) { + return
; + } + + return ( + + + + Complete your signup + + + +
+ + + + + + + +
+
+
+ ); +} diff --git a/apps/client/src/features/auth/components/login-form.tsx b/apps/client/src/features/auth/components/login-form.tsx index 59950f09..9e9376ee 100644 --- a/apps/client/src/features/auth/components/login-form.tsx +++ b/apps/client/src/features/auth/components/login-form.tsx @@ -1,37 +1,41 @@ -import * as React from 'react'; -import * as z from 'zod'; +import * as React from "react"; +import * as z from "zod"; -import { useForm, zodResolver } from '@mantine/form'; -import useAuth from '@/features/auth/hooks/use-auth'; -import { ILogin } from '@/features/auth/types/auth.types'; +import { useForm, zodResolver } from "@mantine/form"; +import useAuth from "@/features/auth/hooks/use-auth"; +import { ILogin } from "@/features/auth/types/auth.types"; import { Container, Title, Anchor, - Paper, TextInput, Button, Text, PasswordInput, -} from '@mantine/core'; -import { Link } from 'react-router-dom'; -import classes from './auth.module.css'; + Box, +} from "@mantine/core"; +import { Link, useNavigate } from "react-router-dom"; +import classes from "./auth.module.css"; +import { useEffect, useState } from "react"; +import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; const formSchema = z.object({ email: z - .string({ required_error: 'email is required' }) - .email({ message: 'Invalid email address' }), - password: z.string({ required_error: 'password is required' }), + .string() + .min(1, { message: "email is required" }) + .email({ message: "Invalid email address" }), + password: z.string().min(1, { message: "Password is required" }), }); export function LoginForm() { const { signIn, isLoading } = useAuth(); + useRedirectIfAuthenticated(); const form = useForm({ validate: zodResolver(formSchema), initialValues: { - email: '', - password: '', + email: "", + password: "", }, }); @@ -40,9 +44,9 @@ export function LoginForm() { } return ( - - - + <Container size={420} my={40} className={classes.container}> + <Box p="xl" mt={200}> + <Title order={2} ta="center" fw={500} mb="md"> Login @@ -52,16 +56,16 @@ export function LoginForm() { type="email" label="Email" placeholder="email@example.com" - required - {...form.getInputProps('email')} + variant="filled" + {...form.getInputProps("email")} /> - + + + Already have an account?{" "} + + Login + + + ); } diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 2e0e786b..11450642 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -1,11 +1,15 @@ -import { useState } from 'react'; -import { login, register } from '@/features/auth/services/auth-service'; -import { useNavigate } from 'react-router-dom'; -import { useAtom } from 'jotai'; -import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom'; -import { currentUserAtom } from '@/features/user/atoms/current-user-atom'; -import { ILogin, IRegister } from '@/features/auth/types/auth.types'; -import { notifications } from '@mantine/notifications'; +import { useState } from "react"; +import { login, register } from "@/features/auth/services/auth-service"; +import { useNavigate } from "react-router-dom"; +import { useAtom } from "jotai"; +import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import { ILogin, IRegister } from "@/features/auth/types/auth.types"; +import { notifications } from "@mantine/notifications"; +import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts"; +import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts"; +import Cookies from "js-cookie"; +import { jwtDecode } from "jwt-decode"; export default function useAuth() { const [isLoading, setIsLoading] = useState(false); @@ -22,12 +26,13 @@ export default function useAuth() { setIsLoading(false); setAuthToken(res.tokens); - navigate('/home'); + navigate("/home"); } catch (err) { + console.log(err); setIsLoading(false); notifications.show({ message: err.response?.data.message, - color: 'red', + color: "red", }); } }; @@ -41,24 +46,72 @@ export default function useAuth() { setAuthToken(res.tokens); - navigate('/home'); + navigate("/home"); } catch (err) { setIsLoading(false); notifications.show({ message: err.response?.data.message, - color: 'red', + color: "red", }); } }; - const hasTokens = () => { + const handleInvitationSignUp = async (data: IAcceptInvite) => { + setIsLoading(true); + + try { + const res = await acceptInvitation(data); + setIsLoading(false); + + console.log(res); + setAuthToken(res.tokens); + + navigate("/home"); + } catch (err) { + setIsLoading(false); + notifications.show({ + message: err.response?.data.message, + color: "red", + }); + } + }; + + const handleIsAuthenticated = async () => { + if (!authToken) { + return false; + } + + try { + const accessToken = authToken.accessToken; + const payload = jwtDecode(accessToken); + + // true if jwt is active + const now = Date.now().valueOf() / 1000; + return payload.exp >= now; + } catch (err) { + console.log("invalid jwt token", err); + return false; + } + }; + + const hasTokens = (): boolean => { return !!authToken; }; const handleLogout = async () => { setAuthToken(null); setCurrentUser(null); + Cookies.remove("authTokens"); + navigate("/login"); }; - return { signIn: handleSignIn, signUp: handleSignUp, isLoading, hasTokens }; + return { + signIn: handleSignIn, + signUp: handleSignUp, + invitationSignup: handleInvitationSignUp, + isAuthenticated: handleIsAuthenticated, + logout: handleLogout, + hasTokens, + isLoading, + }; } diff --git a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts new file mode 100644 index 00000000..ebe1236d --- /dev/null +++ b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import useAuth from "@/features/auth/hooks/use-auth.ts"; + +export function useRedirectIfAuthenticated() { + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + const checkAuth = async () => { + const validAuth = await isAuthenticated(); + if (validAuth) { + navigate("/home"); + } + }; + + checkAuth(); + }, [isAuthenticated]); +} diff --git a/apps/client/src/features/auth/types/auth.types.ts b/apps/client/src/features/auth/types/auth.types.ts index 0e064d98..442677fa 100644 --- a/apps/client/src/features/auth/types/auth.types.ts +++ b/apps/client/src/features/auth/types/auth.types.ts @@ -4,6 +4,7 @@ export interface ILogin { } export interface IRegister { + name?: string; email: string; password: string; } diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index b9191a7d..71d47200 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -1,29 +1,31 @@ -import { Group, Text, Box } from '@mantine/core'; -import React, { useState } from 'react'; -import classes from './comment.module.css'; -import { useAtomValue } from 'jotai'; -import { timeAgo } from '@/lib/time'; -import CommentEditor from '@/features/comment/components/comment-editor'; -import { pageEditorAtom } from '@/features/editor/atoms/editor-atoms'; -import CommentActions from '@/features/comment/components/comment-actions'; -import CommentMenu from '@/features/comment/components/comment-menu'; -import { useHover } from '@mantine/hooks'; -import { useDeleteCommentMutation, useUpdateCommentMutation } from '@/features/comment/queries/comment-query'; -import { IComment } from '@/features/comment/types/comment.types'; -import { UserAvatar } from '@/components/ui/user-avatar'; +import { Group, Text, Box } from "@mantine/core"; +import React, { useState } from "react"; +import classes from "./comment.module.css"; +import { useAtomValue } from "jotai"; +import { timeAgo } from "@/lib/time"; +import CommentEditor from "@/features/comment/components/comment-editor"; +import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; +import CommentActions from "@/features/comment/components/comment-actions"; +import CommentMenu from "@/features/comment/components/comment-menu"; +import { useHover } from "@mantine/hooks"; +import { + useDeleteCommentMutation, + useUpdateCommentMutation, +} from "@/features/comment/queries/comment-query"; +import { IComment } from "@/features/comment/types/comment.types"; +import { UserAvatar } from "@/components/ui/user-avatar"; interface CommentListItemProps { comment: IComment; } function CommentListItem({ comment }: CommentListItemProps) { - const { hovered, ref } = useHover(); const [isEditing, setIsEditing] = useState(false); const [isLoading, setIsLoading] = useState(false); const editor = useAtomValue(pageEditorAtom); - const [content, setContent] = useState(comment.content); + const [content, setContent] = useState(comment.content); const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); @@ -31,13 +33,13 @@ function CommentListItem({ comment }: CommentListItemProps) { try { setIsLoading(true); const commentToUpdate = { - id: comment.id, + commentId: comment.id, content: JSON.stringify(content), }; await updateCommentMutation.mutateAsync(commentToUpdate); setIsEditing(false); } catch (error) { - console.error('Failed to update comment:', error); + console.error("Failed to update comment:", error); } finally { setIsLoading(false); } @@ -48,7 +50,7 @@ function CommentListItem({ comment }: CommentListItemProps) { await deleteCommentMutation.mutateAsync(comment.id); editor?.commands.unsetComment(comment.id); } catch (error) { - console.error('Failed to delete comment:', error); + console.error("Failed to delete comment:", error); } } @@ -59,20 +61,28 @@ function CommentListItem({ comment }: CommentListItemProps) { return ( -
- {comment.creator.name} + + {comment.creator.name} + -
+
{/*!comment.parentCommentId && ( )*/} - +
@@ -83,26 +93,30 @@ function CommentListItem({ comment }: CommentListItemProps) {
- {!comment.parentCommentId && comment?.selection && + {!comment.parentCommentId && comment?.selection && ( {comment?.selection} - } + )} - { - !isEditing ? - () - : - (<> - setContent(newContent)} - autofocus={true} /> - - - ) - } + {!isEditing ? ( + + ) : ( + <> + setContent(newContent)} + autofocus={true} + /> + + + )}
- ); } diff --git a/apps/client/src/features/comment/components/comment-list.tsx b/apps/client/src/features/comment/components/comment-list.tsx index 866293a9..8a4ceda7 100644 --- a/apps/client/src/features/comment/components/comment-list.tsx +++ b/apps/client/src/features/comment/components/comment-list.tsx @@ -18,7 +18,7 @@ function CommentList() { data: comments, isLoading: isCommentsLoading, isError, - } = useCommentsQuery(pageId); + } = useCommentsQuery({ pageId, limit: 100 }); const [isLoading, setIsLoading] = useState(false); const createCommentMutation = useCreateCommentMutation(); diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index 67d5ed75..0cc6d377 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -12,6 +12,7 @@ import { updateComment, } from "@/features/comment/services/comment-service"; import { + ICommentParams, IComment, IResolveComment, } from "@/features/comment/types/comment.types"; @@ -21,12 +22,13 @@ import { IPagination } from "@/lib/types.ts"; export const RQ_KEY = (pageId: string) => ["comments", pageId]; export function useCommentsQuery( - pageId: string, + params: ICommentParams, ): UseQueryResult, Error> { return useQuery({ - queryKey: RQ_KEY(pageId), - queryFn: () => getPageComments(pageId), - enabled: !!pageId, + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: RQ_KEY(params.pageId), + queryFn: () => getPageComments(params), + enabled: !!params.pageId, }); } @@ -36,13 +38,14 @@ export function useCreateCommentMutation() { return useMutation>({ mutationFn: (data) => createComment(data), onSuccess: (data) => { - const newComment = data; - let comments = queryClient.getQueryData(RQ_KEY(data.pageId)); - if (comments) { - // comments = prevComments => [...prevComments, newComment]; - //queryClient.setQueryData(RQ_KEY(data.pageId), comments); - } + //const newComment = data; + // let comments = queryClient.getQueryData(RQ_KEY(data.pageId)); + // if (comments) { + //comments = prevComments => [...prevComments, newComment]; + //queryClient.setQueryData(RQ_KEY(data.pageId), comments); + //} + queryClient.invalidateQueries({ queryKey: RQ_KEY(data.pageId) }); notifications.show({ message: "Comment created successfully" }); }, onError: (error) => { @@ -69,11 +72,21 @@ export function useDeleteCommentMutation(pageId?: string) { return useMutation({ mutationFn: (commentId: string) => deleteComment(commentId), onSuccess: (data, variables) => { - let comments = queryClient.getQueryData(RQ_KEY(pageId)) as IComment[]; - if (comments) { - // comments = comments.filter(comment => comment.id !== variables); - // queryClient.setQueryData(RQ_KEY(pageId), comments); + const comments = queryClient.getQueryData( + RQ_KEY(pageId), + ) as IPagination; + + if (comments && comments.items) { + const commentId = variables; + const newComments = comments.items.filter( + (comment) => comment.id !== commentId, + ); + queryClient.setQueryData(RQ_KEY(pageId), { + ...comments, + items: newComments, + }); } + notifications.show({ message: "Comment deleted successfully" }); }, onError: (error) => { @@ -92,6 +105,7 @@ export function useResolveCommentMutation() { RQ_KEY(data.pageId), ) as IComment[]; + /* if (currentComments) { const updatedComments = currentComments.map((comment) => comment.id === variables.commentId @@ -99,7 +113,7 @@ export function useResolveCommentMutation() { : comment, ); queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments); - } + }*/ notifications.show({ message: "Comment resolved successfully" }); }, diff --git a/apps/client/src/features/comment/services/comment-service.ts b/apps/client/src/features/comment/services/comment-service.ts index d4bb623d..f1512469 100644 --- a/apps/client/src/features/comment/services/comment-service.ts +++ b/apps/client/src/features/comment/services/comment-service.ts @@ -1,5 +1,6 @@ import api from "@/lib/api-client"; import { + ICommentParams, IComment, IResolveComment, } from "@/features/comment/types/comment.types"; @@ -9,30 +10,30 @@ export async function createComment( data: Partial, ): Promise { const req = await api.post("/comments/create", data); - return req.data as IComment; + return req.data; } export async function resolveComment(data: IResolveComment): Promise { const req = await api.post(`/comments/resolve`, data); - return req.data as IComment; + return req.data; } export async function updateComment( data: Partial, ): Promise { const req = await api.post(`/comments/update`, data); - return req.data as IComment; + return req.data; } export async function getCommentById(commentId: string): Promise { const req = await api.post("/comments/info", { commentId }); - return req.data as IComment; + return req.data; } export async function getPageComments( - pageId: string, + data: ICommentParams, ): Promise> { - const req = await api.post("/comments", { pageId }); + const req = await api.post("/comments", data); return req.data; } diff --git a/apps/client/src/features/comment/types/comment.types.ts b/apps/client/src/features/comment/types/comment.types.ts index 93bf94f3..a29e770e 100644 --- a/apps/client/src/features/comment/types/comment.types.ts +++ b/apps/client/src/features/comment/types/comment.types.ts @@ -1,4 +1,5 @@ -import { IUser } from '@/features/user/types/user.types'; +import { IUser } from "@/features/user/types/user.types"; +import { QueryParams } from "@/lib/types.ts"; export interface IComment { id: string; @@ -14,7 +15,7 @@ export interface IComment { createdAt: Date; editedAt?: Date; deletedAt?: Date; - creator: IUser + creator: IUser; } export interface ICommentData { @@ -29,3 +30,7 @@ export interface IResolveComment { commentId: string; resolved: boolean; } + +export interface ICommentParams extends QueryParams { + pageId: string; +} diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css index 4189274d..0d3028fe 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.module.css @@ -1,25 +1,28 @@ .bubbleMenu { - display: flex; - width: fit-content; - border-radius: 2px; - border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8)); + display: flex; + width: fit-content; + border-radius: 2px; + border: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8)); - .active { - color: var(--mantine-color-blue-8); - } + .active { + color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5)); + } - .colorButton { - border: none; - } - - .colorButton::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 1px; - background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8)); - } + .colorButton { + border: none; + } + .colorButton::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 1px; + background-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-gray-8) + ); + } } diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index 33df7612..e93dbfb1 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -4,7 +4,7 @@ import { isNodeSelection, useEditor, } from "@tiptap/react"; -import { FC, useState } from "react"; +import { FC, useEffect, useRef, useState } from "react"; import { IconBold, IconCode, @@ -37,8 +37,13 @@ type EditorBubbleMenuProps = Omit & { }; export const EditorBubbleMenu: FC = (props) => { - const [, setShowCommentPopup] = useAtom(showCommentPopupAtom); + const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [, setDraftCommentId] = useAtom(draftCommentIdAtom); + const showCommentPopupRef = useRef(showCommentPopup); + + useEffect(() => { + showCommentPopupRef.current = showCommentPopup; + }, [showCommentPopup]); const items: BubbleMenuItem[] = [ { @@ -94,9 +99,11 @@ export const EditorBubbleMenu: FC = (props) => { const { empty } = selection; if ( - props.editor.isActive("image") || + !editor.isEditable || + editor.isActive("image") || empty || - isNodeSelection(selection) + isNodeSelection(selection) || + showCommentPopupRef?.current ) { return false; } @@ -117,47 +124,43 @@ export const EditorBubbleMenu: FC = (props) => { const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); return ( - - { - setIsNodeSelectorOpen(!isNodeSelectorOpen); - setIsColorSelectorOpen(false); - setIsLinkSelectorOpen(false); - }} - /> + +
+ { + setIsNodeSelectorOpen(!isNodeSelectorOpen); + }} + /> - - {items.map((item, index) => ( - - - - - - ))} - + + {items.map((item, index) => ( + + + + + + ))} + - { - setIsColorSelectorOpen(!isColorSelectorOpen); - setIsNodeSelectorOpen(false); - setIsLinkSelectorOpen(false); - }} - /> + { + setIsColorSelectorOpen(!isColorSelectorOpen); + }} + /> - = (props) => { > - +
); }; diff --git a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx index a0c76148..12867794 100644 --- a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx @@ -1,6 +1,6 @@ import { Dispatch, FC, SetStateAction } from "react"; import { IconCheck, IconChevronDown } from "@tabler/icons-react"; -import { Button, Popover, rem, ScrollArea, Text } from "@mantine/core"; +import { Button, Popover, rem, ScrollArea, Text, Tooltip } from "@mantine/core"; import classes from "./bubble-menu.module.css"; import { useEditor } from "@tiptap/react"; @@ -110,17 +110,22 @@ export const ColorSelector: FC = ({ return ( - + @@ -159,37 +164,6 @@ export const ColorSelector: FC = ({ ))} - - - BACKGROUND - - - - {HIGHLIGHT_COLORS.map(({ name, color }, index) => ( - - ))} - diff --git a/apps/client/src/features/editor/utils/index.ts b/apps/client/src/features/editor/utils/index.ts new file mode 100644 index 00000000..a9f6bd3b --- /dev/null +++ b/apps/client/src/features/editor/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./is-custom-node-selected"; +export * from "./is-text-selected"; diff --git a/apps/client/src/features/editor/utils/is-custom-node-selected.ts b/apps/client/src/features/editor/utils/is-custom-node-selected.ts new file mode 100644 index 00000000..8ebaba4d --- /dev/null +++ b/apps/client/src/features/editor/utils/is-custom-node-selected.ts @@ -0,0 +1,11 @@ +import { Editor } from "@tiptap/react"; +import TiptapLink from "@tiptap/extension-link"; +import { CodeBlock } from "@tiptap/extension-code-block"; + +export const isCustomNodeSelected = (editor: Editor, node: HTMLElement) => { + const customNodes = [CodeBlock.name, TiptapLink.name]; + + return customNodes.some((type) => editor.isActive(type)); +}; + +export default isCustomNodeSelected; diff --git a/apps/client/src/features/editor/utils/is-text-selected.ts b/apps/client/src/features/editor/utils/is-text-selected.ts new file mode 100644 index 00000000..21c523e9 --- /dev/null +++ b/apps/client/src/features/editor/utils/is-text-selected.ts @@ -0,0 +1,26 @@ +import { isTextSelection } from "@tiptap/core"; +import { Editor } from "@tiptap/react"; + +export const isTextSelected = ({ editor }: { editor: Editor }) => { + const { + state: { + doc, + selection, + selection: { empty, from, to }, + }, + } = editor; + + // Sometime check for `empty` is not enough. + // Doubleclick an empty paragraph returns a node size of 2. + // So we check also for an empty text size. + const isEmptyTextBlock = + !doc.textBetween(from, to).length && isTextSelection(selection); + + if (empty || isEmptyTextBlock || !editor.isEditable) { + return false; + } + + return true; +}; + +export default isTextSelected; diff --git a/apps/client/src/features/group/components/multi-group-select.tsx b/apps/client/src/features/group/components/multi-group-select.tsx index d0b6c1c8..bb70a287 100644 --- a/apps/client/src/features/group/components/multi-group-select.tsx +++ b/apps/client/src/features/group/components/multi-group-select.tsx @@ -8,6 +8,8 @@ import { IconUsersGroup } from "@tabler/icons-react"; interface MultiGroupSelectProps { onChange: (value: string[]) => void; label?: string; + description?: string; + mt?: string; } const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ @@ -21,7 +23,12 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ ); -export function MultiGroupSelect({ onChange, label }: MultiGroupSelectProps) { +export function MultiGroupSelect({ + onChange, + label, + description, + mt, +}: MultiGroupSelectProps) { const [searchValue, setSearchValue] = useState(""); const [debouncedQuery] = useDebouncedValue(searchValue, 500); const { data: groups, isLoading } = useGetGroupsQuery({ @@ -56,8 +63,10 @@ export function MultiGroupSelect({ onChange, label }: MultiGroupSelectProps) { renderOption={renderMultiSelectOption} hidePickedOptions maxDropdownHeight={300} + description={description} label={label || "Add groups"} placeholder="Search for groups" + mt={mt} searchable searchValue={searchValue} onSearchChange={setSearchValue} diff --git a/apps/client/src/features/page/tree/components/space-content.tsx b/apps/client/src/features/page/tree/components/space-content.tsx index 6b41cf4c..7ae2cd65 100644 --- a/apps/client/src/features/page/tree/components/space-content.tsx +++ b/apps/client/src/features/page/tree/components/space-content.tsx @@ -1,18 +1,11 @@ import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useAtom } from "jotai/index"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; -import { - Accordion, - AccordionControlProps, - ActionIcon, - Center, - rem, - Tooltip, -} from "@mantine/core"; -import { IconPlus } from "@tabler/icons-react"; +import { Box } from "@mantine/core"; +import { IconNotes } from "@tabler/icons-react"; import React from "react"; -import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import SpaceTree from "@/features/page/tree/components/space-tree.tsx"; +import { TreeCollapse } from "@/features/page/tree/components/tree-collapse.tsx"; export default function SpaceContent() { const [currentUser] = useAtom(currentUserAtom); @@ -24,42 +17,15 @@ export default function SpaceContent() { return ( <> - - - {space.name} - - - - - + + + + + ); } - -function AccordionControl(props: AccordionControlProps) { - const [tree] = useAtom(treeApiAtom); - - function handleCreatePage() { - //todo: create at the bottom - tree?.create({ parentId: null, type: "internal", index: 0 }); - } - - return ( -
- - {/* - - */} - - - - - -
- ); -} diff --git a/apps/client/src/features/page/tree/components/tree-collapse.module.css b/apps/client/src/features/page/tree/components/tree-collapse.module.css new file mode 100644 index 00000000..9decc50a --- /dev/null +++ b/apps/client/src/features/page/tree/components/tree-collapse.module.css @@ -0,0 +1,27 @@ +.control { + font-weight: 500; + display: block; + width: 100%; + padding: var(--mantine-spacing-xs) var(--mantine-spacing-xs); + color: var(--mantine-color-text); + font-size: var(--mantine-font-size-sm); + + @mixin hover { + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); + color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); + } +} + +.item { + display: block; + text-decoration: none; + padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); + padding-left: 4px; + margin-left: var(--mantine-spacing-sm); + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); +} + +.chevron { + transition: transform 200ms ease; +} diff --git a/apps/client/src/features/page/tree/components/tree-collapse.tsx b/apps/client/src/features/page/tree/components/tree-collapse.tsx new file mode 100644 index 00000000..6d7ce467 --- /dev/null +++ b/apps/client/src/features/page/tree/components/tree-collapse.tsx @@ -0,0 +1,59 @@ +import React, { ReactNode, useState } from "react"; +import { + Group, + Box, + Collapse, + ThemeIcon, + UnstyledButton, + rem, +} from "@mantine/core"; +import { IconChevronRight } from "@tabler/icons-react"; +import classes from "./tree-collapse.module.css"; + +interface LinksGroupProps { + icon?: React.FC; + label: string; + initiallyOpened?: boolean; + children: ReactNode; +} + +export function TreeCollapse({ + icon: Icon, + label, + initiallyOpened, + children, +}: LinksGroupProps) { + const [opened, setOpened] = useState(initiallyOpened || false); + + return ( + <> + setOpened((o) => !o)} + className={classes.control} + > + + + + + + {label} + + + + + + + +
{children}
+
+ + ); +} diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx index 9b8eff67..f1581560 100644 --- a/apps/client/src/features/space/components/settings-modal.tsx +++ b/apps/client/src/features/space/components/settings-modal.tsx @@ -1,17 +1,8 @@ -import { - Modal, - Tabs, - rem, - Group, - Divider, - Text, - ScrollArea, -} from "@mantine/core"; +import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core"; import SpaceMembersList from "@/features/space/components/space-members.tsx"; import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx"; import React from "react"; import GroupActionMenu from "@/features/group/components/group-action-menu.tsx"; -import { ISpace } from "@/features/space/types/space.types.ts"; import SpaceDetails from "@/features/space/components/space-details.tsx"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; @@ -47,7 +38,7 @@ export default function SpaceSettingsModal({
- + Settings diff --git a/apps/client/src/features/user/hooks/use-current-user.ts b/apps/client/src/features/user/hooks/use-current-user.ts index 3d58e4b0..3d6b289d 100644 --- a/apps/client/src/features/user/hooks/use-current-user.ts +++ b/apps/client/src/features/user/hooks/use-current-user.ts @@ -1,12 +1,12 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { getUserInfo } from "@/features/user/services/user-service"; +import { getMyInfo } from "@/features/user/services/user-service"; import { ICurrentUser } from "@/features/user/types/user.types"; export default function useCurrentUser(): UseQueryResult { return useQuery({ queryKey: ["currentUser"], queryFn: async () => { - return await getUserInfo(); + return await getMyInfo(); }, }); } diff --git a/apps/client/src/features/user/services/user-service.ts b/apps/client/src/features/user/services/user-service.ts index 16f2a19a..733abf32 100644 --- a/apps/client/src/features/user/services/user-service.ts +++ b/apps/client/src/features/user/services/user-service.ts @@ -1,29 +1,23 @@ -import api from '@/lib/api-client'; -import { ICurrentUser, IUser } from '@/features/user/types/user.types'; +import api from "@/lib/api-client"; +import { ICurrentUser, IUser } from "@/features/user/types/user.types"; -export async function getMe(): Promise { - const req = await api.post('/users/me'); - return req.data as IUser; -} - -export async function getUserInfo(): Promise { - const req = await api.post('/users/info'); +export async function getMyInfo(): Promise { + const req = await api.post("/users/me"); return req.data as ICurrentUser; } export async function updateUser(data: Partial): Promise { - const req = await api.post('/users/update', data); + const req = await api.post("/users/update", data); return req.data as IUser; } export async function uploadAvatar(file: File) { const formData = new FormData(); - formData.append('avatar', file); - const req = await api.post('/attachments/upload/avatar', formData, { + formData.append("avatar", file); + const req = await api.post("/attachments/upload/avatar", formData, { headers: { - 'Content-Type': 'multipart/form-data', - } + "Content-Type": "multipart/form-data", + }, }); return req.data; } - diff --git a/apps/client/src/features/user/user-provider.tsx b/apps/client/src/features/user/user-provider.tsx index ccdcc8e7..93e9ac6f 100644 --- a/apps/client/src/features/user/user-provider.tsx +++ b/apps/client/src/features/user/user-provider.tsx @@ -1,7 +1,7 @@ -import { useAtom } from 'jotai'; -import { currentUserAtom } from '@/features/user/atoms/current-user-atom'; -import React, { useEffect } from 'react'; -import useCurrentUser from '@/features/user/hooks/use-current-user'; +import { useAtom } from "jotai"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import React, { useEffect } from "react"; +import useCurrentUser from "@/features/user/hooks/use-current-user"; export function UserProvider({ children }: React.PropsWithChildren) { const [, setCurrentUser] = useAtom(currentUserAtom); @@ -16,6 +16,7 @@ export function UserProvider({ children }: React.PropsWithChildren) { if (isLoading) return <>; if (error) { + console.error(error); return <>an error occurred; } diff --git a/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx b/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx new file mode 100644 index 00000000..2097e0c3 --- /dev/null +++ b/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx @@ -0,0 +1,70 @@ +import { Menu, ActionIcon, Text } from "@mantine/core"; +import React from "react"; +import { IconDots, IconTrash } from "@tabler/icons-react"; +import { modals } from "@mantine/modals"; +import { + useResendInvitationMutation, + useRevokeInvitationMutation, +} from "@/features/workspace/queries/workspace-query.ts"; + +interface Props { + invitationId: string; +} +export default function InviteActionMenu({ invitationId }: Props) { + const resendInvitationMutation = useResendInvitationMutation(); + const revokeInvitationMutation = useRevokeInvitationMutation(); + + const onResend = async () => { + await resendInvitationMutation.mutateAsync({ invitationId }); + }; + + const onRevoke = async () => { + await revokeInvitationMutation.mutateAsync({ invitationId }); + }; + + const openRevokeModal = () => + modals.openConfirmModal({ + title: "Revoke invitation", + children: ( + + Are you sure you want to revoke this invitation? The user will not be + able to join the workspace. + + ), + centered: true, + labels: { confirm: "Revoke", cancel: "Don't" }, + confirmProps: { color: "red" }, + onConfirm: onRevoke, + }); + + return ( + <> + + + + + + + + + Resend invitation + + } + > + Revoke invitation + + + + + ); +} diff --git a/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx b/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx index b63e1956..29de4698 100644 --- a/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx +++ b/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx @@ -1,50 +1,87 @@ -import { Group, Box, Button, TagsInput, Space, Select } from "@mantine/core"; +import { Group, Box, Button, TagsInput, Select } from "@mantine/core"; import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section.tsx"; -import React from "react"; +import React, { useState } from "react"; +import { MultiGroupSelect } from "@/features/group/components/multi-group-select.tsx"; +import { UserRole } from "@/lib/types.ts"; +import { userRoleData } from "@/features/workspace/types/user-role-data.ts"; +import { useCreateInvitationMutation } from "@/features/workspace/queries/workspace-query.ts"; +import { useNavigate } from "react-router-dom"; -enum UserRole { - OWNER = "Owner", - ADMIN = "Admin", - MEMBER = "Member", +interface Props { + onClose: () => void; } +export function WorkspaceInviteForm({ onClose }: Props) { + const [emails, setEmails] = useState([]); + const [role, setRole] = useState(UserRole.MEMBER); + const [groupIds, setGroupIds] = useState([]); + const createInvitationMutation = useCreateInvitationMutation(); + const navigate = useNavigate(); -export function WorkspaceInviteForm() { - function handleSubmit(data) { - console.log(data); + async function handleSubmit() { + const validEmails = emails.filter((email) => { + const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return regex.test(email); + }); + + await createInvitationMutation.mutateAsync({ + role: role.toLowerCase(), + emails: validEmails, + groupIds: groupIds, + }); + + onClose(); + + navigate("?tab=invites"); } + const handleGroupSelect = (value: string[]) => { + setGroupIds(value); + }; + return ( <> - - - + {/* */} - -