diff --git a/apps/client/app/api/collections/index.ts b/apps/client/app/api/collections/index.ts index a93ff709..b90c9375 100644 --- a/apps/client/app/api/collections/index.ts +++ b/apps/client/app/api/collections/index.ts @@ -265,3 +265,83 @@ export const useGetCollectionContentsBySlug = ({ enabled: Boolean(slug), retry: retryQuery, }) + +interface AddContentsToCollection { + collectionId: string + contentIds: Array<{ type: 'CONTENT' | 'TAG' | 'COLLECTION'; id: string }> +} + +export const addContentsToCollection = async ( + input: AddContentsToCollection, +) => { + try { + const response = await fetchClient>( + `/v1/collections/${input.collectionId}/contents`, + { + method: 'POST', + body: JSON.stringify(input), + }, + ) + + if (!response.parsedBody.status && response.parsedBody.errorMessage) { + throw new Error(response.parsedBody.errorMessage) + } + + return response.parsedBody.data + } catch (error: unknown) { + if (error instanceof Error) { + throw error + } + + // Error from server. + if (error instanceof Response) { + const response = await error.json() + throw new Error(response.errorMessage) + } + } +} + +export const useAddContentsToCollection = () => + useMutation({ + mutationFn: addContentsToCollection, + }) + +interface RemoveContentsToCollection { + collectionId: string + contentIds: Array +} + +export const removeContentsToCollection = async ( + input: RemoveContentsToCollection, +) => { + try { + const response = await fetchClient>( + `/v1/collections/${input.collectionId}/contents`, + { + method: 'DELETE', + body: JSON.stringify(input), + }, + ) + + if (!response.parsedBody.status && response.parsedBody.errorMessage) { + throw new Error(response.parsedBody.errorMessage) + } + + return response.parsedBody.data + } catch (error: unknown) { + if (error instanceof Error) { + throw error + } + + // Error from server. + if (error instanceof Response) { + const response = await error.json() + throw new Error(response.errorMessage) + } + } +} + +export const useRemoveContentsToCollection = () => + useMutation({ + mutationFn: removeContentsToCollection, + }) diff --git a/apps/client/app/components/Content/index.tsx b/apps/client/app/components/Content/index.tsx index c7e803f3..3d97b01c 100644 --- a/apps/client/app/components/Content/index.tsx +++ b/apps/client/app/components/Content/index.tsx @@ -97,7 +97,7 @@ export const Content = ({ content, showCreator = true }: Props) => { FlyoutContent={

- This is image is hidden from the public + This image is hidden from the public

} diff --git a/apps/client/app/modules/account/home/collections/index.tsx b/apps/client/app/modules/account/home/collections/index.tsx index e7980d2a..f00b2121 100644 --- a/apps/client/app/modules/account/home/collections/index.tsx +++ b/apps/client/app/modules/account/home/collections/index.tsx @@ -17,6 +17,7 @@ export function AccountCollectionsModule() { created_by: currentUser?.id, visibility: 'ALL', }, + populate: ['content'], }) if (isPending) { diff --git a/apps/client/app/modules/collections/single/components/add-image-contents-modal.tsx b/apps/client/app/modules/collections/single/components/add-image-contents-modal.tsx new file mode 100644 index 00000000..c921c6c2 --- /dev/null +++ b/apps/client/app/modules/collections/single/components/add-image-contents-modal.tsx @@ -0,0 +1,291 @@ +import { CheckCircleIcon } from '@heroicons/react/20/solid' +import { + ArrowPathIcon, + MagnifyingGlassIcon, + PlusIcon, +} from '@heroicons/react/24/outline' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useMemo, useState } from 'react' +import { Image } from 'remix-image' +import { + useAddContentsToCollection, + useGetCollectionContentsBySlug, +} from '@/api/collections/index.ts' +import { FadeIn, FadeInStagger } from '@/components/animation/FadeIn.tsx' +import { Button } from '@/components/button/index.tsx' +import { EmptyState } from '@/components/empty-state/index.tsx' +import { ErrorState } from '@/components/error-state/index.tsx' +import { Loader } from '@/components/loader/index.tsx' +import { Modal } from '@/components/modal/index.tsx' +import { blurDataURL, PAGES, QUERY_KEYS } from '@/constants/index.ts' +import { classNames } from '@/lib/classNames.ts' +import { errorToast } from '@/lib/custom-toast-functions.tsx' +import { safeString } from '@/lib/strings.ts' +import { useAuth } from '@/providers/auth/index.tsx' + +interface Props { + collection: Collection + isOpened: boolean + onClose: () => void + existingContents: Array +} + +const MAX_CONTENTS = 50 + +export function AddImageContentsModal({ + existingContents, + collection, + isOpened, + onClose, +}: Props) { + const { currentUser } = useAuth() + const queryClient = useQueryClient() + const { data, isError, isPending, refetch } = useGetCollectionContentsBySlug({ + slug: `${safeString(currentUser?.id)}_uploads`, + query: { + pagination: { page: 0, per: 50 }, + populate: ['content', 'content.tags'], + }, + }) + const { mutate, isPending: isSubmitting } = useAddContentsToCollection() + const [selectedContents, setSelectedContents] = useState>([]) + + const totalContentsAdded = useMemo( + () => existingContents.length + selectedContents.length, + [existingContents, selectedContents], + ) + + const handleAddingContent = (id: string) => { + if (selectedContents.includes(id)) { + setSelectedContents( + selectedContents.filter((contentId) => contentId !== id), + ) + } else { + setSelectedContents([...selectedContents, id]) + } + } + + const isSelected = useCallback( + (id: string) => Boolean(selectedContents.includes(id)), + [selectedContents], + ) + + const isAlreadySelected = useCallback( + (id: string) => + existingContents.some((content) => content.content?.id === id), + [existingContents], + ) + + const isAddDisabled = useMemo( + () => selectedContents.length === 0 || isSubmitting, + [isSubmitting, selectedContents.length], + ) + + const handleSubmit = () => { + mutate( + { + collectionId: collection.id, + contentIds: selectedContents.map((id) => ({ type: 'CONTENT', id })), + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ + QUERY_KEYS.COLLECTIONS, + collection.slug, + 'slug-contents', + ], + }) + onClose() + }, + onError: () => { + errorToast('Adding contents failed. Try again later.') + }, + }, + ) + } + + let contentJsx = <> + + if (isPending) { + contentJsx = ( +
+ +
+ ) + } else if (isError) { + contentJsx = ( +
+ + + +
+ ) + } else if (data && !data?.total) { + contentJsx = ( +
+ + + +
+ ) + } else if (data?.total) { + contentJsx = ( + +
+ {data.rows.map((colletionContent) => + colletionContent.content ? ( +
+ + = MAX_CONTENTS} + isSelected={isSelected(colletionContent.content.id)} + isAlreadySelected={isAlreadySelected( + colletionContent.content.id, + )} + content={colletionContent.content} + onClick={() => + handleAddingContent( + safeString(colletionContent?.content?.id), + ) + } + /> + {colletionContent.content.visibility === 'PRIVATE' ? ( +
+
+ Hidden +
+
+ ) : null} +
+
+ ) : null, + )} +
+
+ ) + } + + return ( + +
+
+ +
+
+
+ {selectedContents.length} Selected + + {totalContentsAdded}/{MAX_CONTENTS} contents in collection + +
+
+ {selectedContents.length > 0 ? ( + + ) : null} +
+
+ +
{contentJsx}
+
+
+ + + +
+
+ ) +} + +interface ContentProps { + content: Content + isAlreadySelected?: boolean + isSelected?: boolean + isDisabled?: boolean + onClick?: () => void +} + +function Content({ + content, + isAlreadySelected = false, + isSelected = false, + onClick, + isDisabled: isDisabledProp = false, +}: ContentProps) { + const isDisabled = isAlreadySelected || isDisabledProp + + return ( +
+ {content.title} +
+ {isAlreadySelected || isSelected ? ( +
+ +
+ ) : null} +
+
+ ) +} diff --git a/apps/client/app/modules/collections/single/components/remove-image-content-dialog.tsx b/apps/client/app/modules/collections/single/components/remove-image-content-dialog.tsx new file mode 100644 index 00000000..7b0559cf --- /dev/null +++ b/apps/client/app/modules/collections/single/components/remove-image-content-dialog.tsx @@ -0,0 +1,70 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useRemoveContentsToCollection } from '@/api/collections/index.ts' +import { Button } from '@/components/button/index.tsx' +import { Modal } from '@/components/modal/index.tsx' +import { QUERY_KEYS } from '@/constants/index.ts' +import { errorToast } from '@/lib/custom-toast-functions.tsx' +import { safeString } from '@/lib/strings.ts' + +interface Props { + isOpened: boolean + onClose: () => void + collectionSlug: string + collectionContent?: CollectionContent +} + +export function RemoveImageContentModal({ + collectionContent, + collectionSlug, + isOpened, + onClose, +}: Props) { + const queryClient = useQueryClient() + const { isPending: isSubmitting, mutate } = useRemoveContentsToCollection() + + const handleSubmit = () => { + if (!collectionContent) return + mutate( + { + collectionId: collectionContent?.collectionId, + contentIds: [safeString(collectionContent?.contentId)], + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.COLLECTIONS, collectionSlug, 'slug-contents'], + }) + onClose() + }, + onError: () => { + errorToast('Removing content failed. Please try again.') + }, + }, + ) + } + return ( + +
+
+

+ Remove "{collectionContent?.content?.title}"? +

+

Image can be added again later.

+
+
+ + + +
+
+
+ ) +} diff --git a/apps/client/app/modules/collections/single/index.tsx b/apps/client/app/modules/collections/single/index.tsx index dde773a9..657fa567 100644 --- a/apps/client/app/modules/collections/single/index.tsx +++ b/apps/client/app/modules/collections/single/index.tsx @@ -1,6 +1,9 @@ import { ArrowPathIcon, ChevronLeftIcon } from '@heroicons/react/24/outline' import { useLoaderData, useNavigate, useParams } from '@remix-run/react' import dayjs from 'dayjs' +import { useState } from 'react' +import { AddImageContentsModal } from './components/add-image-contents-modal.tsx' +import { RemoveImageContentModal } from './components/remove-image-content-dialog.tsx' import { useGetCollectionContentsBySlug } from '@/api/collections/index.ts' import { FadeIn } from '@/components/animation/FadeIn.tsx' import { Button } from '@/components/button/index.tsx' @@ -13,15 +16,20 @@ import { Loader } from '@/components/loader/index.tsx' import { ShareButton } from '@/components/share-button/index.tsx' import { UserImage } from '@/components/user-image.tsx' import { PAGES } from '@/constants/index.ts' +import { useDisclosure } from '@/hooks/use-disclosure.tsx' import { safeString } from '@/lib/strings.ts' import { useAuth } from '@/providers/auth/index.tsx' import { type loader } from '@/routes/collections.$collection.ts' export function CollectionModule() { const { collection } = useLoaderData() + const addContentsModalState = useDisclosure() const navigate = useNavigate() const { currentUser } = useAuth() const { collection: collectionParam } = useParams() + const removeContentsModalState = useDisclosure() + const [selectedCollectionContent, setSelectedCollectionContent] = + useState() const isCollectionMine = collection?.createdBy?.id === currentUser?.id @@ -30,6 +38,7 @@ export function CollectionModule() { query: { pagination: { page: 0, per: 50 }, filters: { visibility: isCollectionMine ? 'ALL' : 'PUBLIC' }, + populate: ['content'], }, }) @@ -92,11 +101,25 @@ export function CollectionModule() { if (data?.total) { content = ( -
+
{data.rows.map((collectionContent) => ( -
+
{collectionContent.content ? ( - + <> + +
+ +
+ ) : null}
))} @@ -139,9 +162,11 @@ export function CollectionModule() {
) : null} - - Published on {dayjs(collection?.createdAt).format('L')} - +
+ + Created on {dayjs(collection?.createdAt).format('L')} + +
@@ -157,7 +182,11 @@ export function CollectionModule() {

- {isCollectionMine ? : null} + {isCollectionMine ? ( + + ) : null} @@ -169,6 +198,22 @@ export function CollectionModule() { {content}