From 6756f3cf932e205361f774918c42d649392dafab Mon Sep 17 00:00:00 2001 From: David Bermudez Date: Fri, 21 Mar 2025 17:57:11 -0400 Subject: [PATCH] Content Feed New Post --- packages/components/index.ts | 7 + .../graphql/mutations/ContentPostCreate.ts | 26 +++ .../modules/content-feed/common/index.ts | 3 + .../content-feed/web/ContentFeed/index.tsx | 38 ++++ .../content-feed/web/ContentFeed/styled.tsx | 24 +++ .../content-feed/web/ContentFeed/types.ts | 3 + .../web/ContentFeedImage/index.tsx | 186 ++++++++++++++++++ .../web/ContentFeedImage/styled.tsx | 83 ++++++++ .../web/ContentFeedImage/types.ts | 12 ++ .../web/NewContentPost/constants.ts | 13 ++ .../content-feed/web/NewContentPost/index.tsx | 116 +++++++++++ .../web/NewContentPost/styled.tsx | 24 +++ .../content-feed/web/NewContentPost/types.ts | 10 + .../modules/content-feed/web/index.ts | 10 + packages/components/package.json | 5 + packages/components/schema.graphql | 97 +++++++++ 16 files changed, 657 insertions(+) create mode 100644 packages/components/index.ts create mode 100644 packages/components/modules/content-feed/common/graphql/mutations/ContentPostCreate.ts create mode 100644 packages/components/modules/content-feed/common/index.ts create mode 100644 packages/components/modules/content-feed/web/ContentFeed/index.tsx create mode 100644 packages/components/modules/content-feed/web/ContentFeed/styled.tsx create mode 100644 packages/components/modules/content-feed/web/ContentFeed/types.ts create mode 100644 packages/components/modules/content-feed/web/ContentFeedImage/index.tsx create mode 100644 packages/components/modules/content-feed/web/ContentFeedImage/styled.tsx create mode 100644 packages/components/modules/content-feed/web/ContentFeedImage/types.ts create mode 100644 packages/components/modules/content-feed/web/NewContentPost/constants.ts create mode 100644 packages/components/modules/content-feed/web/NewContentPost/index.tsx create mode 100644 packages/components/modules/content-feed/web/NewContentPost/styled.tsx create mode 100644 packages/components/modules/content-feed/web/NewContentPost/types.ts create mode 100644 packages/components/modules/content-feed/web/index.ts diff --git a/packages/components/index.ts b/packages/components/index.ts new file mode 100644 index 00000000..c3a8c5c0 --- /dev/null +++ b/packages/components/index.ts @@ -0,0 +1,7 @@ +export * from './modules/__shared__' +export * from './modules/comments' +export * from './modules/messages' +export * from './modules/navigations' +export * from './modules/content-feed' +export * from './modules/profiles' +export * from './modules/notifications' diff --git a/packages/components/modules/content-feed/common/graphql/mutations/ContentPostCreate.ts b/packages/components/modules/content-feed/common/graphql/mutations/ContentPostCreate.ts new file mode 100644 index 00000000..32cd5b37 --- /dev/null +++ b/packages/components/modules/content-feed/common/graphql/mutations/ContentPostCreate.ts @@ -0,0 +1,26 @@ +import { graphql, useMutation } from 'react-relay' + +import { ContentPostCreateMutation } from '../../../../../__generated__/ContentPostCreateMutation.graphql' + +export const ContentPostCreateMutationQuery = graphql` + mutation ContentPostCreateMutation($input: ContentPostCreateInput!) { + contentPostCreate(input: $input) { + contentPost { + node { + id + content + author { + email + } + } + } + errors { + field + messages + } + } + } +` + +export const useContentPostCreateMutation = () => + useMutation(ContentPostCreateMutationQuery) diff --git a/packages/components/modules/content-feed/common/index.ts b/packages/components/modules/content-feed/common/index.ts new file mode 100644 index 00000000..3f1ebff6 --- /dev/null +++ b/packages/components/modules/content-feed/common/index.ts @@ -0,0 +1,3 @@ +// exports common content-feed code + +export * from './graphql/mutations/ContentPostCreate' diff --git a/packages/components/modules/content-feed/web/ContentFeed/index.tsx b/packages/components/modules/content-feed/web/ContentFeed/index.tsx new file mode 100644 index 00000000..cae8bdb1 --- /dev/null +++ b/packages/components/modules/content-feed/web/ContentFeed/index.tsx @@ -0,0 +1,38 @@ +'use client' + +import { FC, useCallback } from 'react' + +import { Button, Typography } from '@mui/material' +import { useRouter } from 'next/navigation' + +import { HeaderContainer, RootContainer } from './styled' +import { IContentFeedProps } from './types' + +const ContentFeed: FC = () => { + const router = useRouter() + + const onNewPost = useCallback(() => { + router.push('/new-post') + }, [router]) + + return ( + + + + Content Feed + + + + + ) +} + +export default ContentFeed diff --git a/packages/components/modules/content-feed/web/ContentFeed/styled.tsx b/packages/components/modules/content-feed/web/ContentFeed/styled.tsx new file mode 100644 index 00000000..c1cd7117 --- /dev/null +++ b/packages/components/modules/content-feed/web/ContentFeed/styled.tsx @@ -0,0 +1,24 @@ +import { Box, styled } from '@mui/material' + +export const RootContainer = styled(Box)(() => ({ + display: 'flex', + width: '600px', + alignSelf: 'center', + flexDirection: 'column', +})) + +export const HeaderContainer = styled(Box)(() => ({ + display: 'flex', + width: '100%', + alignSelf: 'center', + flexDirection: 'row', + justifyContent: 'space-between', +})) + +export const ButtonContainer = styled(Box)(() => ({ + display: 'flex', + width: 'fit-content', + flexDirection: 'row', + justifyContent: 'space-between', + gap: '10px', +})) diff --git a/packages/components/modules/content-feed/web/ContentFeed/types.ts b/packages/components/modules/content-feed/web/ContentFeed/types.ts new file mode 100644 index 00000000..d994f2ed --- /dev/null +++ b/packages/components/modules/content-feed/web/ContentFeed/types.ts @@ -0,0 +1,3 @@ +export interface IContentFeedProps { + +} \ No newline at end of file diff --git a/packages/components/modules/content-feed/web/ContentFeedImage/index.tsx b/packages/components/modules/content-feed/web/ContentFeedImage/index.tsx new file mode 100644 index 00000000..902feaa0 --- /dev/null +++ b/packages/components/modules/content-feed/web/ContentFeedImage/index.tsx @@ -0,0 +1,186 @@ +'use client' + +import { ChangeEventHandler, FC, useRef, useState } from 'react' + +import { useNotification } from '@baseapp-frontend/utils' + +import {AddRounded as AddRoundedIcon} from '@mui/icons-material' +import {CloseRounded as CloseRoundedIcon} from '@mui/icons-material' +import {InsertPhotoOutlined as InsertPhotoOutlinedIcon} from '@mui/icons-material' +import { Box, Typography } from '@mui/material' +import Image from 'next/image' +import { Controller } from 'react-hook-form' + +import { + AddFileButton, + AddFileWrapper, + ContentFeedImageContainer, + DropFilesContainer, + MiniatureFileWrapper, + RemoveFileButton, +} from './styled' +import { IContentFeedImageProps } from './types' + +const ContentFeedImage: FC = ({ form }) => { + const [selectedUploadedFile, setSelectedUploadedFiles] = useState() + const [isDragging, setIsDragging] = useState(false) + + const DEFAULT_IMAGE_FORMATS = 'image/png, image/gif, image/jpeg' + const DEFAULT_IMAGE_MAX_SIZE = 10 * 1024 * 1024 + + const fileRef = useRef(null) + const { sendToast } = useNotification() + + const { control, watch } = form + + const formFiles: File[] = watch('images') + + const handleRemoveFile = (fileIndex: number) => { + const updatedFiles = formFiles?.filter((_, index) => index !== fileIndex) + form.setValue('images', updatedFiles as never, { shouldValidate: true }) + } + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragging(false) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const { files } = e.dataTransfer + if (files.length) { + form.setValue('images', [...(formFiles as never[]), ...(files as unknown as never[])]) + } + } + + return ( + + {selectedUploadedFile && !isDragging && ( + + {selectedUploadedFile.name} URL.revokeObjectURL(URL.createObjectURL(selectedUploadedFile))} + /> + + )} + + {(isDragging || !formFiles?.length) && ( + fileRef?.current?.click()}> + + + Click to browse or drag and drop images and videos. + + + Max. File Size: 15MB + + + )} + + + {!!formFiles?.length && ( + + fileRef?.current?.click()} disableRipple> + + + + )} + { + const handleOnChange: ChangeEventHandler = ( + event, + ) => { + const { files } = event.target as HTMLInputElement + + if (files) { + // Convert FileList to an array of File objects + const filesArray = Array.from(files) + + // Filter and process valid files + const validFiles = filesArray.filter((file) => { + if (file.size > DEFAULT_IMAGE_MAX_SIZE) { + // Notify the user if the file is too large + sendToast( + `This file is too large (max ${DEFAULT_IMAGE_MAX_SIZE / 1024 / 1024}MB).`, + { + type: 'error', + }, + ) + return false // Exclude this file + } + return true // Include this file + }) + + // Update the form field with the valid files + if (validFiles.length > 0) { + field.onChange([...formFiles, ...validFiles]) + } + } + } + return ( + + ) + }} + /> + {formFiles?.map((file, index) => ( + + + handleRemoveFile(index)}> + + + + ))} + + + ) +} + +export default ContentFeedImage diff --git a/packages/components/modules/content-feed/web/ContentFeedImage/styled.tsx b/packages/components/modules/content-feed/web/ContentFeedImage/styled.tsx new file mode 100644 index 00000000..82805e8d --- /dev/null +++ b/packages/components/modules/content-feed/web/ContentFeedImage/styled.tsx @@ -0,0 +1,83 @@ +import { Box, Button, styled } from '@mui/material' + +export const ContentFeedImageContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + marginBottom: '16px', + overflow: 'auto', + paddingBottom: '6px', + '::-webkit-scrollbar': { + height: '6px', + }, + '::-webkit-scrollbar-track': { + boxShadow: `inset 0 0 1px ${theme.palette.grey[400]}`, + borderRadius: '10px', + }, + '::-webkit-scrollbar-thumb': { + background: theme.palette.grey[400], + borderRadius: '10px', + }, + '::-webkit-scrollbar-thumb:hover': { + background: theme.palette.grey[600], + }, +})) + +export const AddFileButton = styled(Button)(({ theme }) => ({ + width: '72px', + height: '72px', + backgroundColor: theme.palette.grey[200], + borderRadius: '8px', + '&:hover': { + backgroundColor: theme.palette.grey[300], + }, +})) + +export const AddFileWrapper = styled(Box)(({ theme }) => ({ + border: `2px dashed ${theme.palette.grey[200]}`, + borderRadius: '12px', + padding: '4px', + display: 'inline-block', + flexShrink: 0, +})) + +export const MiniatureFileWrapper = styled(Box)(({ theme }) => ({ + position: 'relative', + flexShrink: 0, + width: '80px', + height: '80px', + border: `2px solid ${theme.palette.grey[200]}`, + borderRadius: '12px', + padding: '4px', + display: 'inline-block', + '&:hover': { + border: `2px solid black`, + }, +})) + +export const RemoveFileButton = styled('button')(({ theme }) => ({ + position: 'absolute', + top: '4px', + right: '4px', + width: '28px', + height: '28px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '0 6px 0 6px', + backgroundColor: theme.palette.grey[800], + '&:hover': { + backgroundColor: theme.palette.grey[800], + }, +})) + +export const DropFilesContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', + border: `1px dashed ${theme.palette.grey[400]}`, + borderImageSlice: 1, + width: '100%', + height: '144px', + borderRadius: '8px', + marginBottom: '8px', +})) diff --git a/packages/components/modules/content-feed/web/ContentFeedImage/types.ts b/packages/components/modules/content-feed/web/ContentFeedImage/types.ts new file mode 100644 index 00000000..20daf608 --- /dev/null +++ b/packages/components/modules/content-feed/web/ContentFeedImage/types.ts @@ -0,0 +1,12 @@ +import { UseFormReturn } from "react-hook-form" + +export interface IContentFeedImageProps { + form: UseFormReturn< + { + content: string + images: never[] + }, + any, + undefined + > +} \ No newline at end of file diff --git a/packages/components/modules/content-feed/web/NewContentPost/constants.ts b/packages/components/modules/content-feed/web/NewContentPost/constants.ts new file mode 100644 index 00000000..ff58c0f9 --- /dev/null +++ b/packages/components/modules/content-feed/web/NewContentPost/constants.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +import { ContentPostCreateForm } from './types' + +export const DEFAULT_CONTENT_POST_CREATE_FORM_VALUES = { + content: '', + images: [], +} satisfies ContentPostCreateForm + +export const CONTENT_POST_CREATE_FORM_VALIDATION = z.object({ + content: z.string(), + images: z.array(z.instanceof(File)), +} satisfies Record) diff --git a/packages/components/modules/content-feed/web/NewContentPost/index.tsx b/packages/components/modules/content-feed/web/NewContentPost/index.tsx new file mode 100644 index 00000000..a1652c02 --- /dev/null +++ b/packages/components/modules/content-feed/web/NewContentPost/index.tsx @@ -0,0 +1,116 @@ +'use client' + +import { FC, useCallback } from 'react' + +import { TextField } from '@baseapp-frontend/design-system/components/web/inputs' +import { setFormRelayErrors, useNotification } from '@baseapp-frontend/utils' + +import { zodResolver } from '@hookform/resolvers/zod' +import LoadingButton from '@mui/lab/LoadingButton' +import { Box, Button, Typography } from '@mui/material' +import { useRouter } from 'next/navigation' +import { useForm } from 'react-hook-form' + +import { useContentPostCreateMutation } from '../../common/graphql/mutations/ContentPostCreate' +import ContentFeedImage from '../ContentFeedImage' +import { + CONTENT_POST_CREATE_FORM_VALIDATION, + DEFAULT_CONTENT_POST_CREATE_FORM_VALUES, +} from './constants' +import { ButtonContainer, HeaderContainer, RootContainer } from './styled' +import { ContentPostCreateForm, INewContentPostProps, UploadableContentPostFiles } from './types' + +const NewContentPost: FC = () => { + const router = useRouter() + const { sendToast } = useNotification() + const [commitMutation, isMutationInFlight] = useContentPostCreateMutation() + + const formReturn = useForm({ + defaultValues: DEFAULT_CONTENT_POST_CREATE_FORM_VALUES, + resolver: zodResolver(CONTENT_POST_CREATE_FORM_VALIDATION), + mode: 'onBlur', + }) + + const { + control, + handleSubmit, + reset, + formState: { isDirty, isValid }, + } = formReturn + + const onSubmit = handleSubmit((data: ContentPostCreateForm) => { + const uploadables: UploadableContentPostFiles = {} + const imageKeys: string[] = [] + + if (data.images) { + data.images.forEach((image, index) => { + uploadables[`image-${index}`] = image as File + imageKeys.push(`image-${index}`) + }) + } + + commitMutation({ + variables: { + input: { + content: data.content, + images: imageKeys, + }, + }, + uploadables, + onCompleted(response) { + const errors = response.contentPostCreate?.errors + if (errors) { + sendToast('Something went wrong', { type: 'error' }) + setFormRelayErrors(formReturn, errors) + } else { + reset({ content: '' }) + sendToast('Post Created Successfully', { type: 'success' }) + router.push(`/content-post/${response.contentPostCreate?.contentPost?.node?.id}`) + } + }, + }) + }) + + const onCancel = useCallback(() => { + router.push('/feed') + }, [router]) + + return ( + +
+ + + New Post + + + + + Publish + + + + + + + + +
+ ) +} + +export default NewContentPost diff --git a/packages/components/modules/content-feed/web/NewContentPost/styled.tsx b/packages/components/modules/content-feed/web/NewContentPost/styled.tsx new file mode 100644 index 00000000..ac1938a2 --- /dev/null +++ b/packages/components/modules/content-feed/web/NewContentPost/styled.tsx @@ -0,0 +1,24 @@ +import { Box, styled } from '@mui/material' + +export const RootContainer = styled(Box)(() => ({ + dispaly: 'flex', + width: '600px', + alignSelf: 'center', + flexDirection: 'column', +})) + +export const HeaderContainer = styled(Box)(() => ({ + display: 'flex', + width: '100%', + alignSelf: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: '32px', +})) + +export const ButtonContainer = styled(Box)(() => ({ + display: 'flex', + width: 'fit-content', + flexDirection: 'row', + gap: '10px', +})) diff --git a/packages/components/modules/content-feed/web/NewContentPost/types.ts b/packages/components/modules/content-feed/web/NewContentPost/types.ts new file mode 100644 index 00000000..8396baad --- /dev/null +++ b/packages/components/modules/content-feed/web/NewContentPost/types.ts @@ -0,0 +1,10 @@ +export interface ContentPostCreateForm { + content: string + images?: string[] | File[] | Blob[] +} + +export type UploadableContentPostFiles = { + [key: `image-${number}`]: File | Blob +} + +export interface INewContentPostProps {} \ No newline at end of file diff --git a/packages/components/modules/content-feed/web/index.ts b/packages/components/modules/content-feed/web/index.ts new file mode 100644 index 00000000..8cace7dc --- /dev/null +++ b/packages/components/modules/content-feed/web/index.ts @@ -0,0 +1,10 @@ +// exports content feed components + +export { default as ContentFeed } from './ContentFeed' +export type * from './ContentFeed/types' + +export { default as ContentFeedImage } from './ContentFeedImage' +export type * from './ContentFeedImage/types' + +export { default as NewContentPost } from './NewContentPost' +export type * from './NewContentPost/types' diff --git a/packages/components/package.json b/packages/components/package.json index 1b550633..a8e68123 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -60,6 +60,11 @@ "types": "./dist/profiles/*/index.d.ts", "import": "./dist/profiles/*/index.mjs", "require": "./dist/profiles/*/index.js" + }, + "./content-feed/*": { + "types": "./dist/content-feed/*/index.d.ts", + "import": "./dist/content-feed/*/index.mjs", + "require": "./dist/content-feed/*/index.js" } }, "files": [ diff --git a/packages/components/schema.graphql b/packages/components/schema.graphql index 99edce81..b8239d91 100644 --- a/packages/components/schema.graphql +++ b/packages/components/schema.graphql @@ -480,6 +480,58 @@ type CommentUpdatePayload { clientMutationId: String } +type ContentPost implements Node { + author: User + content: String! + + """The ID of the object""" + id: ID! + pk: Int! + contentImages: [ContentPostImage] +} + +type ContentPostConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [ContentPostEdge]! + totalCount: Int + edgeCount: Int +} + +input ContentPostCreateInput { + content: String! + images: [String] + clientMutationId: String +} + +type ContentPostCreatePayload { + """May contain more than one error for same field.""" + errors: [ErrorType] + _debug: DjangoDebug + contentPost: ContentPostEdge + clientMutationId: String +} + +"""A Relay edge containing a `ContentPost` and its cursor.""" +type ContentPostEdge { + """The item at the end of the edge""" + node: ContentPost + + """A cursor for use in pagination""" + cursor: String! +} + +type ContentPostImage implements Node { + image: String + post: ContentPost! + + """The ID of the object""" + id: ID! + pk: Int! +} + """ The `Date` scalar type represents a Date value as specified by @@ -726,6 +778,8 @@ type Mutation { notificationsMarkAsRead(input: NotificationsMarkAsReadInput!): NotificationsMarkAsReadPayload notificationsMarkAllAsRead(input: NotificationsMarkAllAsReadInput!): NotificationsMarkAllAsReadPayload notificationSettingToggle(input: NotificationSettingToggleInput!): NotificationSettingTogglePayload + contentPostCreate(input: ContentPostCreateInput!): ContentPostCreatePayload + contentPosts(offset: Int, before: String, after: String, first: Int, last: Int): ContentPostConnection commentCreate(input: CommentCreateInput!): CommentCreatePayload commentUpdate(input: CommentUpdateInput!): CommentUpdatePayload commentPin(input: CommentPinInput!): CommentPinPayload @@ -1397,6 +1451,7 @@ type Query { """The ID of the object""" id: ID! ): Report + contentPosts(offset: Int, before: String, after: String, first: Int, last: Int): ContentPostConnection comment( """The ID of the object""" id: ID! @@ -1834,3 +1889,45 @@ enum VisibilityTypes { INTERNAL } +type ContentPost implements Node { + author: User + content: String! + """The ID of the object""" + id: ID! + pk: Int! + contentImages: [ContentPostImage] +} +type ContentPostConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + """Contains the nodes in this connection.""" + edges: [ContentPostEdge]! + totalCount: Int + edgeCount: Int +} +input ContentPostCreateInput { + content: String! + images: [String] + clientMutationId: String +} +type ContentPostCreatePayload { + """May contain more than one error for same field.""" + errors: [ErrorType] + _debug: DjangoDebug + contentPost: ContentPostEdge + clientMutationId: String +} +"""A Relay edge containing a `ContentPost` and its cursor.""" +type ContentPostEdge { + """The item at the end of the edge""" + node: ContentPost + """A cursor for use in pagination""" + cursor: String! +} +type ContentPostImage implements Node { + image: String + post: ContentPost! + """The ID of the object""" + id: ID! + pk: Int! +}