From cf0fe801d7568994082dc673485c9773a7ac1ea8 Mon Sep 17 00:00:00 2001 From: David Bermudez Date: Wed, 9 Apr 2025 17:18:41 -0400 Subject: [PATCH] content-feed-new-post --- packages/components/CHANGELOG.md | 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 | 1 + .../web/ContentFeedImage/index.tsx | 72 +++++++ .../web/ContentFeedImage/types.ts | 12 ++ .../web/NewContentPost/constants.ts | 13 ++ .../content-feed/web/NewContentPost/index.tsx | 113 ++++++++++ .../web/NewContentPost/styled.tsx | 24 +++ .../content-feed/web/NewContentPost/types.ts | 10 + .../modules/content-feed/web/index.ts | 10 + packages/components/package.json | 7 +- packages/components/schema.graphql | 125 +++++++++-- packages/config/.eslintrc.js | 1 + packages/design-system/CHANGELOG.md | 6 + .../Dropzone/DropzonePreview/index.tsx | 50 +++++ .../Dropzone/__storybook__/stories.tsx | 2 + .../web/dropzones/Dropzone/index.tsx | 195 ++++++++++++------ .../web/dropzones/Dropzone/styled.tsx | 98 ++++++++- .../web/dropzones/Dropzone/types.ts | 56 ++++- packages/design-system/package.json | 2 +- packages/wagtail/CHANGELOG.md | 7 + packages/wagtail/package.json | 2 +- 25 files changed, 812 insertions(+), 92 deletions(-) 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/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 create mode 100644 packages/design-system/components/web/dropzones/Dropzone/DropzonePreview/index.tsx diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 7c47e596..fd4c117e 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,5 +1,12 @@ # @baseapp-frontend/components +## 1.0.34 + +### Patch Changes +- Content Feed creation page and Dropzone Improvements +- Updated dependencies + - @baseapp-frontend/design-system@1.0.15 + ## 1.0.33 ### Patch Changes 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..c9fbfd12 --- /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 + user { + 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..8c8cd228 --- /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 { ContentFeedProps } from './types' + +const ContentFeed: FC = () => { + const router = useRouter() + + const onNewPost = useCallback(() => { + router.push('/posts/new') + }, [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..e9fcf522 --- /dev/null +++ b/packages/components/modules/content-feed/web/ContentFeed/types.ts @@ -0,0 +1 @@ +export interface ContentFeedProps {} 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..5c4788d1 --- /dev/null +++ b/packages/components/modules/content-feed/web/ContentFeedImage/index.tsx @@ -0,0 +1,72 @@ +'use client' + +import { FC, useState } from 'react' + +import { Dropzone } from '@baseapp-frontend/design-system/components/web/dropzones' + +import { Box, Typography } from '@mui/material' +import Image from 'next/image' + +import { IContentFeedImageProps } from './types' + +const ContentFeedImage: FC = ({ form }) => { + const [selectedPreview, setSelectedPreview] = useState() + const [selectedPreviewIndex, setSelectedPreviewIndex] = useState() + + const { watch } = form + + const formFiles = watch('images') + + const handleRemoveFile = (fileIndex?: number) => { + const updatedFiles = formFiles?.filter((_, index) => index !== fileIndex) + form.setValue('images', updatedFiles as File[], { shouldValidate: true }) + + if (selectedPreviewIndex === fileIndex) { + setSelectedPreview(undefined) + setSelectedPreviewIndex(undefined) + } + } + + const onSelect = (files: (File | Blob)[]) => { + if (files.length) { + form.setValue('images', [...formFiles, ...(files as File[])]) + } + } + + return ( + + {selectedPreview && ( + + {selectedPreview.name} URL.revokeObjectURL(URL.createObjectURL(selectedPreview))} + /> + + )} + + Click to browse or drag and drop images and videos. + + } + onFileClick={(selectedFile, index) => { + setSelectedPreview(selectedFile as File) + setSelectedPreviewIndex(index) + }} + /> + + ) +} + +export default ContentFeedImage 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..d0d04150 --- /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: File[] + }, + any, + undefined + > +} 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..fb8d4ced --- /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: [] as File[], +} + +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..23b572e8 --- /dev/null +++ b/packages/components/modules/content-feed/web/NewContentPost/index.tsx @@ -0,0 +1,113 @@ +'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, NewContentPostProps, 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 = {} + + if (data.images) { + data.images.forEach((image, index) => { + uploadables[`images.${index}`] = image as File + }) + } + + commitMutation({ + variables: { + input: { + content: data.content, + }, + }, + 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(`/posts/${response.contentPostCreate?.contentPost?.node?.id}`) + } + }, + }) + }) + + const onCancel = useCallback(() => { + router.push('/posts') + }, [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..3ffdb132 --- /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)(() => ({ + display: '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..5c98118e --- /dev/null +++ b/packages/components/modules/content-feed/web/NewContentPost/types.ts @@ -0,0 +1,10 @@ +export interface ContentPostCreateForm { + content: string + images?: File[] +} + +export type UploadableContentPostFiles = { + [key: `images.${number}`]: File | Blob +} + +export type NewContentPostProps = Record 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 df59f17d..51af24e1 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/components", "description": "BaseApp components modules such as comments, notifications, messages, and more.", - "version": "1.0.33", + "version": "1.0.34", "sideEffects": false, "scripts": { "babel:transpile": "babel modules -d tmp-babel --extensions .ts,.tsx --ignore '**/__tests__/**','**/__storybook__/**'", @@ -65,6 +65,11 @@ "types": "./dist/tests/*/index.d.ts", "import": "./dist/tests/*/index.mjs", "require": "./dist/tests/*/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 ec5740fc..78ba9f7d 100644 --- a/packages/components/schema.graphql +++ b/packages/components/schema.graphql @@ -12,7 +12,6 @@ type ActivityLog implements Node { user: User ipAddress: String verb: String - diff: GenericScalar visibility: VisibilityTypes url: String pk: Int! @@ -202,6 +201,7 @@ type ChatRoomOnMessagesCountUpdate { type ChatRoomOnRoomUpdate { room: ChatRoomEdge removedParticipants: [ChatRoomParticipant] + addedParticipants: [ChatRoomParticipant] } type ChatRoomParticipant implements Node { @@ -325,6 +325,7 @@ type ChatRoomUpdatePayload { _debug: DjangoDebug room: ChatRoomEdge removedParticipants: [ChatRoomParticipant] + addedParticipants: [ChatRoomParticipant] clientMutationId: String } @@ -480,6 +481,78 @@ type CommentUpdatePayload { clientMutationId: String } +type ContentPost implements Node { + user: User! + profile: Profile + content: String! + images(offset: Int, before: String, after: String, first: Int, last: Int, id: ID): ContentPostImageConnection + + """The ID of the object""" + id: ID! + pk: Int! +} + +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! +} + +type ContentPostImageConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [ContentPostImageEdge]! + totalCount: Int + edgeCount: Int +} + +"""A Relay edge containing a `ContentPostImage` and its cursor.""" +type ContentPostImageEdge { + """The item at the end of the edge""" + node: ContentPostImage + + """A cursor for use in pagination""" + cursor: String! +} + """ The `Date` scalar type represents a Date value as specified by @@ -497,6 +570,19 @@ scalar DateTime """The `Decimal` scalar type represents a python Decimal.""" scalar Decimal +input DeleteNodeInput { + id: ID! + clientMutationId: String +} + +type DeleteNodePayload { + """May contain more than one error for same field.""" + errors: [ErrorType] + _debug: DjangoDebug + deletedID: ID! + clientMutationId: String +} + """Debugging information for the current query.""" type DjangoDebug { """Executed SQL queries for this API query.""" @@ -726,12 +812,14 @@ type Mutation { notificationsMarkAsRead(input: NotificationsMarkAsReadInput!): NotificationsMarkAsReadPayload notificationsMarkAllAsRead(input: NotificationsMarkAllAsReadInput!): NotificationsMarkAllAsReadPayload notificationSettingToggle(input: NotificationSettingToggleInput!): NotificationSettingTogglePayload + contentPostCreate(input: ContentPostCreateInput!): ContentPostCreatePayload commentCreate(input: CommentCreateInput!): CommentCreatePayload commentUpdate(input: CommentUpdateInput!): CommentUpdatePayload commentPin(input: CommentPinInput!): CommentPinPayload commentDelete(input: CommentDeleteInput!): CommentDeletePayload pageCreate(input: PageCreateInput!): PageCreatePayload pageEdit(input: PageEditInput!): PageEditPayload + pageDelete(input: DeleteNodeInput!): DeleteNodePayload profileCreate(input: ProfileCreateInput!): ProfileCreatePayload profileUpdate(input: ProfileUpdateInput!): ProfileUpdatePayload profileDelete(input: ProfileDeleteInput!): ProfileDeletePayload @@ -1146,20 +1234,20 @@ type Profile implements Node & PermissionsInterface & PageInterface & FollowsInt status: ProfilesProfileStatusChoices! owner: User! comments( - offset: Int - before: String - after: String first: Int last: Int + offset: Int + after: String + before: String q: String """Ordering""" orderBy: String ): CommentConnection! - reactions(offset: Int, before: String, after: String, first: Int, last: Int, id: ID): ReactionConnection! - ratings(offset: Int, before: String, after: String, first: Int, last: Int): RateConnection! + reactions(first: Int, last: Int, offset: Int, after: String, before: String, id: ID): ReactionConnection! + ratings(first: Int, last: Int, offset: Int, after: String, before: String): RateConnection! user: User - activitylogSet(offset: Int, before: String, after: String, first: Int, last: Int, createdFrom: Date, createdTo: Date, userPk: Decimal, profilePk: Decimal, userName: String): ActivityLogConnection! + activitylogSet(first: Int, last: Int, offset: Int, after: String, before: String, createdFrom: Date, createdTo: Date, userPk: Decimal, profilePk: Decimal, userName: String): ActivityLogConnection! members( offset: Int before: String @@ -1172,11 +1260,12 @@ type Profile implements Node & PermissionsInterface & PageInterface & FollowsInt orderBy: String q: String ): ProfileUserRoleConnection - chatroomparticipantSet(offset: Int, before: String, after: String, first: Int, last: Int, profile_TargetContentType: ID): ChatRoomParticipantConnection! - unreadmessagecountSet(offset: Int, before: String, after: String, first: Int, last: Int): UnreadMessageCountConnection! - linkedAsContentActorSet(offset: Int, before: String, after: String, first: Int, last: Int, verb: Verbs): MessageConnection! - linkedAsContentTargetSet(offset: Int, before: String, after: String, first: Int, last: Int, verb: Verbs): MessageConnection! - messageSet(offset: Int, before: String, after: String, first: Int, last: Int, verb: Verbs): MessageConnection! + chatroomparticipantSet(first: Int, last: Int, offset: Int, after: String, before: String, profile_TargetContentType: ID): ChatRoomParticipantConnection! + unreadmessagecountSet(first: Int, last: Int, offset: Int, after: String, before: String): UnreadMessageCountConnection! + linkedAsContentActorSet(first: Int, last: Int, offset: Int, after: String, before: String, verb: Verbs): MessageConnection! + linkedAsContentTargetSet(first: Int, last: Int, offset: Int, after: String, before: String, verb: Verbs): MessageConnection! + messageSet(first: Int, last: Int, offset: Int, after: String, before: String, verb: Verbs): MessageConnection! + contentPosts(first: Int, last: Int, offset: Int, after: String, before: String): ContentPostConnection! following(offset: Int, before: String, after: String, first: Int, last: Int, targetIsFollowingBack: Boolean): FollowConnection followers(offset: Int, before: String, after: String, first: Int, last: Int, targetIsFollowingBack: Boolean): FollowConnection blocking(offset: Int, before: String, after: String, first: Int, last: Int): BlockConnection @@ -1397,6 +1486,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! @@ -1744,19 +1834,19 @@ type User implements Node & PermissionsInterface & NotificationsInterface & Page """ isActive: Boolean! isStaff: Boolean - reactions(offset: Int, before: String, after: String, first: Int, last: Int, id: ID): ReactionConnection! + reactions(first: Int, last: Int, offset: Int, after: String, before: String, id: ID): ReactionConnection! comments( - offset: Int - before: String - after: String first: Int last: Int + offset: Int + after: String + before: String q: String """Ordering""" orderBy: String ): CommentConnection! - pages(offset: Int, before: String, after: String, first: Int, last: Int, status: PageStatus): PageConnection! + pages(first: Int, last: Int, offset: Int, after: String, before: String, status: PageStatus): PageConnection! """The ID of the object""" id: ID! @@ -1794,7 +1884,6 @@ type User implements Node & PermissionsInterface & NotificationsInterface & Page activityLogs(visibility: VisibilityTypes, first: Int = 10, offset: Int, before: String, after: String, last: Int, createdFrom: Date, createdTo: Date, userPk: Decimal, profilePk: Decimal, userName: String): ActivityLogConnection isAuthenticated: Boolean fullName: String - shortName: String avatar(width: Int!, height: Int!): File } diff --git a/packages/config/.eslintrc.js b/packages/config/.eslintrc.js index 7e082a1c..9ab62da4 100644 --- a/packages/config/.eslintrc.js +++ b/packages/config/.eslintrc.js @@ -62,6 +62,7 @@ module.exports = { }, ], '@baseapp-frontend/no-process-env-comparison': 'error', + 'react/no-array-index-key': 0, }, settings: { react: { diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index d70654e5..c395927f 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -1,5 +1,11 @@ # @baseapp-frontend/design-system +## 1.0.15 + +### Patch Changes + +- Content Feed creation page and Dropzone Improvements + ## 1.0.14 ### Patch Changes diff --git a/packages/design-system/components/web/dropzones/Dropzone/DropzonePreview/index.tsx b/packages/design-system/components/web/dropzones/Dropzone/DropzonePreview/index.tsx new file mode 100644 index 00000000..2d5fd1f6 --- /dev/null +++ b/packages/design-system/components/web/dropzones/Dropzone/DropzonePreview/index.tsx @@ -0,0 +1,50 @@ +import { FC, useEffect, useState } from 'react' + +import { CloseRounded as CloseRoundedIcon } from '@mui/icons-material' + +import { FileWrapper, RemoveFileButton } from '../styled' +import { DropzonePreviewProps } from '../types' + +const DropzonePreview: FC = ({ + isMini = true, + file, + handleRemoveFile, + onFileClick, +}) => { + const [objectUrl, setObjectUrl] = useState(undefined) + + useEffect(() => { + if (typeof file !== 'string') { + const url = URL.createObjectURL(file) + setObjectUrl(url) + return () => URL.revokeObjectURL(url) + } + return undefined + }, [file]) + + const imageUrl = typeof file === 'string' ? file : objectUrl + + return ( + + + + + + + + ) +} + +export default DropzonePreview diff --git a/packages/design-system/components/web/dropzones/Dropzone/__storybook__/stories.tsx b/packages/design-system/components/web/dropzones/Dropzone/__storybook__/stories.tsx index ff5ad00c..202a0a5e 100644 --- a/packages/design-system/components/web/dropzones/Dropzone/__storybook__/stories.tsx +++ b/packages/design-system/components/web/dropzones/Dropzone/__storybook__/stories.tsx @@ -19,6 +19,8 @@ export const Default: Story = { maxFileSize: 15, subTitle: 'Max. File Size: 15MB', storedImg: undefined, + multiple: false, + asBase64: true, }, } diff --git a/packages/design-system/components/web/dropzones/Dropzone/index.tsx b/packages/design-system/components/web/dropzones/Dropzone/index.tsx index 292c7548..e52a6ee4 100644 --- a/packages/design-system/components/web/dropzones/Dropzone/index.tsx +++ b/packages/design-system/components/web/dropzones/Dropzone/index.tsx @@ -1,18 +1,25 @@ 'use client' -import React, { FC, useState } from 'react' +import { FC, useState } from 'react' import { getImageString, useNotification } from '@baseapp-frontend/utils' -import { Box, Button, Card, Typography } from '@mui/material' +import { + AddRounded as AddRoundedIcon, + InsertPhotoOutlined as InsertPhotoOutlinedIcon, +} from '@mui/icons-material' +import { Button, Card, Typography } from '@mui/material' import { useDropzone } from 'react-dropzone' +import DropzonePreview from './DropzonePreview' import { + AddFileButton, + AddFileWrapper, ButtonContainer, CancelIcon, + DropzoneContainer, DropzoneText, InputContainer, - PortraitOutlinedIcon, } from './styled' import { DropzoneProps } from './types' @@ -21,82 +28,140 @@ const Dropzone: FC = ({ storedImg, onSelect, onRemove, + includeActionButton = false, actionText = 'Upload Image', maxFileSize = 15, + title, subTitle = `Max. File Size: ${maxFileSize}MB`, DropzoneOptions, InputProps, + InputContainerStyle, + multiple = false, + asBase64 = true, + onFileClick, }) => { - const [files, setFiles] = useState(storedImg) + const [files, setFiles] = useState(storedImg) const { sendToast } = useNotification() const { open, getRootProps, getInputProps, isFocused, isDragAccept, isDragReject } = useDropzone({ accept, - onDrop: async (acceptedFiles: any) => { - if (acceptedFiles.length === 0) return - if (acceptedFiles[0].size > maxFileSize * 1024 * 1024) { + onDrop: async (acceptedFiles) => { + if (acceptedFiles.length === 0 || !acceptedFiles[0]) return + if ((acceptedFiles[0]?.size || 0) > maxFileSize * 1024 * 1024) { sendToast(`This file is too large (max ${maxFileSize} MB).`, { type: 'error' }) return } - const imgString = await getImageString(acceptedFiles[0]) - if (!imgString) return - setFiles(imgString) - onSelect(imgString) + + if (multiple) { + const resultingFiles = await Promise.all( + acceptedFiles.map(async (f) => (asBase64 ? getImageString(f) : f)), + ) + const newFiles = [...((files || []) as File[]), ...resultingFiles] + setFiles(newFiles as any) + onSelect(newFiles as any) + } else { + const file = asBase64 ? await getImageString(acceptedFiles[0]) : acceptedFiles[0] + if (!file) return + + setFiles(file) + onSelect(file as any) + } }, ...DropzoneOptions, }) - const handleRemove = () => { - setFiles(undefined) - onRemove() + const handleRemove = (index?: number) => { + if (multiple) { + const updatedFiles = (files as File[])?.filter((file, i) => i !== index) + setFiles(updatedFiles) + onRemove(index) + } else { + setFiles(undefined) + onRemove() + } } + const hasFiles = Boolean((!multiple && files) || (multiple && (files as [])?.length)) + const renderContent = () => { const ariaLabel = 'Drag and drop files to upload' - if (files) - return ( - <> - - - - preview - - - - ) - return (
- - - {isDragReject ? ( - <> - - - File not accepted, please choose the correct type - - - {subTitle} - - - ) : ( - <> - - - Click to browse or drag and drop. - - - {subTitle} - - - )} - + {!hasFiles && ( + + + {isDragReject ? ( + <> + + + File not accepted, please choose the correct type + + + {subTitle} + + + ) : ( + <> + + + {title || ( + <> + Click to browse or drag and drop. + + )} + + + {subTitle} + + + )} + + )} + {!!(files as [])?.length && multiple && ( + + + + + + + + {(files as unknown as [])?.map((file, index) => ( + onFileClick?.(selectedFile, index)} + file={file} + key={`${(file as File)?.name}_${index}`} + handleRemoveFile={() => handleRemove(index)} + /> + ))} + + )} + + {files && !multiple && ( + + + + handleRemove()} + /> + + )}
) } @@ -104,16 +169,18 @@ const Dropzone: FC = ({ return (
{renderContent()} - - - {files && ( - - )} - + {files && ( + + )} + + )}
) } diff --git a/packages/design-system/components/web/dropzones/Dropzone/styled.tsx b/packages/design-system/components/web/dropzones/Dropzone/styled.tsx index ad7c63d3..a565bd26 100644 --- a/packages/design-system/components/web/dropzones/Dropzone/styled.tsx +++ b/packages/design-system/components/web/dropzones/Dropzone/styled.tsx @@ -1,11 +1,107 @@ import { ComponentType } from 'react' import { CancelOutlined, PortraitOutlined } from '@mui/icons-material' -import { Box, BoxProps } from '@mui/material' +import { Box, BoxProps, Button } from '@mui/material' import { alpha, styled } from '@mui/material/styles' import { DropzoneTextProps, InputContainerProps } from './types' +export const DropzoneContainer = styled(Box, { + shouldForwardProp: (prop) => + prop !== 'isDragAccept' && prop !== 'isDragReject' && prop !== 'isFocused', +})(({ 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 FileWrapper = styled(Box, { + shouldForwardProp: (prop) => prop !== 'isMini', +})<{ isMini: boolean }>(({ theme, isMini }) => + isMini + ? { + 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`, + }, + } + : { + padding: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, +) + +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', +})) + const getColor = ({ theme, isDragAccept, isDragReject, isFocused }: InputContainerProps) => { if (isDragAccept) { return theme?.palette.success.main diff --git a/packages/design-system/components/web/dropzones/Dropzone/types.ts b/packages/design-system/components/web/dropzones/Dropzone/types.ts index eba1d03c..46d0fe24 100644 --- a/packages/design-system/components/web/dropzones/Dropzone/types.ts +++ b/packages/design-system/components/web/dropzones/Dropzone/types.ts @@ -1,9 +1,9 @@ -import { HTMLAttributes, InputHTMLAttributes } from 'react' +import { CSSProperties, HTMLAttributes, InputHTMLAttributes } from 'react' import { Theme } from '@mui/material/styles' import type { Accept, DropzoneOptions } from 'react-dropzone' -export interface InputContainerProps { +export interface InputContainerProps extends HTMLAttributes { theme?: Theme isDragAccept: boolean isDragReject: boolean @@ -14,14 +14,58 @@ export interface DropzoneTextProps extends HTMLAttributes { hasError?: boolean } -export interface DropzoneProps { +export interface BaseDropzoneProps { accept: Accept - storedImg?: string - onSelect: (imgString: string) => void - onRemove: () => void + onRemove: (index?: number) => void actionText?: string subTitle?: string maxFileSize?: number DropzoneOptions?: Partial InputProps?: InputHTMLAttributes + title?: string | JSX.Element + includeActionButton: boolean + InputContainerStyle?: CSSProperties + multiple?: boolean + onFileClick?: (selectedFile: string | File | Blob, index?: number) => void +} + +export interface DropzoneAsSingleBase64Props extends BaseDropzoneProps { + multiple?: false + asBase64: true + storedImg?: string + onSelect: (file: string) => void } + +export interface DropzoneAsSingleFileProps extends BaseDropzoneProps { + multiple?: false + asBase64?: false + storedImg?: File | Blob + onSelect: (file: File | Blob) => void +} + +export interface DropzoneAsMultipleBase64Props extends BaseDropzoneProps { + multiple: true + asBase64: true + storedImg?: string[] + onSelect: (files: string[]) => void +} + +export interface DropzoneAsMultipleFileProps extends BaseDropzoneProps { + multiple: true + asBase64?: false + storedImg?: (File | Blob)[] + onSelect: (files: (File | Blob)[]) => void +} + +export interface DropzonePreviewProps { + isMini?: boolean + handleRemoveFile: () => void + file: string | File | Blob + onFileClick?: (selectedFile: string | File | Blob) => void +} + +export type DropzoneProps = + | DropzoneAsSingleBase64Props + | DropzoneAsSingleFileProps + | DropzoneAsMultipleBase64Props + | DropzoneAsMultipleFileProps diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 269df6a7..e187521d 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/design-system", "description": "Design System components and configurations.", - "version": "1.0.14", + "version": "1.0.15", "sideEffects": false, "scripts": { "tsup:bundle": "tsup --tsconfig tsconfig.build.json", diff --git a/packages/wagtail/CHANGELOG.md b/packages/wagtail/CHANGELOG.md index 47deb958..9b6487bd 100644 --- a/packages/wagtail/CHANGELOG.md +++ b/packages/wagtail/CHANGELOG.md @@ -1,5 +1,12 @@ # @baseapp-frontend/wagtail +## 1.0.30 + +### Patch Changes + +- Updated dependencies + - @baseapp-frontend/design-system@1.0.15 + ## 1.0.29 ### Patch Changes diff --git a/packages/wagtail/package.json b/packages/wagtail/package.json index 83d4fe2b..5db73b39 100644 --- a/packages/wagtail/package.json +++ b/packages/wagtail/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/wagtail", "description": "BaseApp Wagtail", - "version": "1.0.29", + "version": "1.0.30", "main": "./index.ts", "types": "dist/index.d.ts", "sideEffects": false,