diff --git a/src/assets/icons/BannerIcon.tsx b/src/assets/icons/BannerIcon.tsx new file mode 100644 index 00000000..378acd2f --- /dev/null +++ b/src/assets/icons/BannerIcon.tsx @@ -0,0 +1,17 @@ +export const BannerIcon = () => { + return ( + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/assets/icons/MercurConnect.tsx b/src/assets/icons/MercurConnect.tsx index 0f567bc7..8fe965a9 100644 --- a/src/assets/icons/MercurConnect.tsx +++ b/src/assets/icons/MercurConnect.tsx @@ -30,9 +30,9 @@ export const MercurConnect = () => { width="24" height="24" filterUnits="userSpaceOnUse" - color-interpolation-filters="sRGB" + colorInterpolationFilters="sRGB" > - + { + return ( + + + + + + ) +} \ No newline at end of file diff --git a/src/components/common/file-upload/file-upload.tsx b/src/components/common/file-upload/file-upload.tsx index f05c45ac..1e651296 100644 --- a/src/components/common/file-upload/file-upload.tsx +++ b/src/components/common/file-upload/file-upload.tsx @@ -9,6 +9,7 @@ export interface FileType { } export interface FileUploadProps { + disabled?: boolean label: string multiple?: boolean hint?: string @@ -18,6 +19,7 @@ export interface FileUploadProps { } export const FileUpload = ({ + disabled = false, label, hint, multiple = true, @@ -95,6 +97,7 @@ export const FileUpload = ({ return (
- - - - - - ) + {t('collections.createCollectionHint')} + +
+
+ { + return ( + + + {t('fields.title')} + + + + + + + ); + }} + /> + { + return ( + + + {t('fields.handle')} + + + + + + + ); + }} + /> +
+
+ ( + + Media + + handleMediaChange(field.onChange, next)} + /> + + + + )} + /> +
+ {form.watch().media.map(media => { + const isThumbnail = !!(media).isThumbnail; + const isBanner = !!(media).isBanner; + + return ( + +
+ +
+ + {media.file.name} + +
+ {isThumbnail && } + {isBanner && } + + {formatFileSize(media.file.size)} + +
+
+
+
+ + + + + + + { + form.setValue( + 'media', + promoteExclusive(form.getValues().media, media.id, 'isThumbnail'), + { shouldDirty: true } + ); + }} + > +
+ + {isThumbnail ? 'Remove thumbnail' : 'Make thumbnail'} +
+ + + +
+ { + form.setValue( + 'media', + promoteExclusive(form.getValues().media, media.id, 'isBanner'), + { shouldDirty: true } + ); + }} + > +
+ + {isBanner ? 'Remove banner' : 'Make banner'} +
+ + + +
+
+ + + form.setValue( + 'media', + form.getValues().media.filter((m: MediaItem) => m.id !== media.id), + { shouldDirty: true } + ) + } + > + + Delete + + +
+
+ +
+
+ ); + })} +
+
+
+ {form.watch().icon ? ( + +
+ +
+ + {form.watch().icon?.[0].file.name} + +
+ + {formatFileSize(form.watch().icon?.[0].file.size ?? 0)} + +
+
+
+ +
+ ) : ( + ( + + Icon + + + + + + )} + /> + )} + + + This icon will appear near the collection label on the storefront. + +
+ + + ); +}; + +function formatFileSize(bytes: number, decimalPlaces: number = 2): string { + if (bytes === 0) { + return '0 Bytes'; + } + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(decimalPlaces)) + ' ' + sizes[i]; } diff --git a/src/routes/collections/collection-create/components/create-collection-rank/create-collection-rank.tsx b/src/routes/collections/collection-create/components/create-collection-rank/create-collection-rank.tsx new file mode 100644 index 00000000..9aa7b6a5 --- /dev/null +++ b/src/routes/collections/collection-create/components/create-collection-rank/create-collection-rank.tsx @@ -0,0 +1,111 @@ +import { UseFormReturn, useWatch } from 'react-hook-form'; +import { + collectionMediaType, + CreateCollectionSchema +} from '../create-collelction-modal/create-collection-modal'; +import * as zod from 'zod'; +import { useMemo, useState } from 'react'; +import { useCollections } from '@hooks/api'; +import { insertCollectionTreeItem } from '@routes/collections/common/utils'; +import { Badge } from '@medusajs/ui'; +import { useTranslation } from 'react-i18next'; +import { CollectionTree } from '@routes/collections/common/components/collection-tree/collection-tree'; + +type CollectionTreeItem = { + id: string; + title: string; + rank: number | null; +}; + +export const CreateCollectionRank = ({ + form, + shouldFreeze +}: { + form: UseFormReturn & collectionMediaType>; + shouldFreeze: boolean; +}) => { + const { t } = useTranslation(); + const [snapshot, setSnapshot] = useState([]); + + const ID = 'new-item'; + + const { collections, isPending } = useCollections({ + limit: 9999 + }); + + const watchedRank = useWatch({ + control: form.control, + name: 'rank' + }); + + const watchedTitle = useWatch({ + control: form.control, + name: 'title' + }); + + const value = useMemo(() => { + const temp = { + id: ID, + title: watchedTitle, + rank: watchedRank + }; + + return insertCollectionTreeItem((collections as unknown as CollectionTreeItem[]) ?? [], temp); + }, [collections, watchedTitle, watchedRank]); + + const ready = !isPending && !!collections?.length; + + const normalizeRanks = (list: CollectionTreeItem[]) => { + return list.map((item, index) => ({ ...item, rank: index })); + }; + + const handleChange = ( + { + index: _index + }: { + index: number; + }, + list: CollectionTreeItem[] + ) => { + const nextIndex = list.findIndex((i) => i.id === ID); + + form.setValue('rank', Math.max(0, nextIndex), { + shouldDirty: true, + shouldTouch: true + }); + + setSnapshot(normalizeRanks(list)); + }; + + return ( +
+ item.id === ID} + onChange={handleChange} + renderValue={item => { + if (item.id === ID) { + return ( +
+ {item.title} + + {t('categories.fields.new.label')} + +
+ ); + } + + return item.title; + }} + /> +
+ ); +}; diff --git a/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx b/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx new file mode 100644 index 00000000..2820aa87 --- /dev/null +++ b/src/routes/collections/collection-create/components/create-collelction-modal/create-collection-modal.tsx @@ -0,0 +1,284 @@ +import { FileType } from '@components/common/file-upload'; +import { RouteFocusModal, useRouteModal } from '@components/modals'; +import { KeyboundForm } from '@components/utilities/keybound-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useDocumentDirection } from '@hooks/use-document-direction'; +import { Button, ProgressStatus, ProgressTabs, toast } from '@medusajs/ui'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import zod from 'zod'; +import { CreateCollectionForm } from '../create-collection-form'; +import { useCreateCollection, usePostCollectionDetails } from '@hooks/api'; +import { CreateCollectionRank } from '../create-collection-rank/create-collection-rank'; +import { sdk } from '@lib/client'; + +export const CreateCollectionSchema = zod.object({ + title: zod.string().min(1), + handle: zod.string().optional(), + rank: zod.number().nullable().default(null), + media: zod + .array( + zod.object({ + id: zod.string(), + url: zod.string(), + isThumbnail: zod.boolean().optional(), + isBanner: zod.boolean().optional(), + file: zod.any() + }) + ) + .default([]), + icon: zod + .array( + zod.object({ + id: zod.string(), + url: zod.string(), + file: zod.any() + }) + ) + .nullable() + .default(null) +}); + +export type MediaItem = FileType & { + isThumbnail?: boolean; + isBanner?: boolean; +}; + +export type collectionMediaType = { + media: MediaItem[]; + icon: FileType[] | null; +}; + +enum Tab { + DETAILS = 'details', + ORGANIZE = 'organize' +} + +export const CreateCollectionModal = () => { + const { t } = useTranslation(); + const direction = useDocumentDirection(); + const [activeTab, setActiveTab] = useState(Tab.DETAILS); + const [validDetails, setValidDetails] = useState(false); + const [shouldFreeze, setShouldFreeze] = useState(false); + + const { handleSuccess } = useRouteModal(); + + const { mutateAsync, isPending } = useCreateCollection(); + + const { mutateAsync: postCollectionDetailsMutation } = usePostCollectionDetails(); + + const form = useForm & collectionMediaType>({ + defaultValues: { + title: '', + handle: '', + media: [], + icon: null, + rank: null + }, + resolver: zodResolver(CreateCollectionSchema) + }); + + const handleSubmit = form.handleSubmit(async data => { + setShouldFreeze(true); + const { title, handle, rank, media, icon } = data; + + await mutateAsync( + { title, handle }, + { + onSuccess: async ({ collection }) => { + if (media.length > 0) { + const { files: uploads } = await sdk.admin.upload + .create({ files: media.map(m => m.file) as unknown as File[] }) + .catch(() => { + return { files: [] }; + }); + + const mediaToCreate = uploads.map((item: { id: string; url: string }) => ({ + url: item.url, + alt_text: '' + })); + + const thumbnailIndex = media.findIndex(m => !!m.isThumbnail); + const bannerIndex = media.find(m => !!m.isBanner) ? media.findIndex(m => !!m.isBanner) : undefined; + + const thumbnail = + thumbnailIndex >= 0 + ? { url: mediaToCreate[thumbnailIndex]?.url } + : { url: mediaToCreate[0]?.url }; + const banner = bannerIndex ? { url: mediaToCreate[bannerIndex]?.url } : undefined; + + let create = mediaToCreate + + const indexesToBeRemoved = [thumbnailIndex, bannerIndex].filter(Boolean); + + while(indexesToBeRemoved.length) { + create.splice(indexesToBeRemoved.pop() as number, 1); + } + + await postCollectionDetailsMutation({ + id: collection.id, + payload: { + media: { delete: [], create }, + rank: rank ?? 0, + thumbnail, + banner + } + }); + } + + if (icon?.length) { + const { files: uploadedIcon } = await sdk.admin.upload + .create({ + files: icon.map(i => i.file) as unknown as File[] + }) + .catch(() => { + form.setError('media', { + type: 'invalid_file', + message: t('products.media.failedToUpload') + }); + return { files: [] }; + }); + await postCollectionDetailsMutation({ + id: collection.id, + payload: { + media: { delete: [], create: [] }, + rank: rank ?? 0, + icon: {url: uploadedIcon[0]?.url} + } + }); + } + + handleSuccess(`/collections/${collection.id}`); + toast.success(t('collections.createSuccess')); + }, + onError: error => { + toast.error(error.message); + setShouldFreeze(false); + } + } + ); + }); + + const handleTabChange = (tab: Tab) => { + if (tab === Tab.ORGANIZE) { + const { title } = form.getValues(); + + const result = CreateCollectionSchema.safeParse({ + title + }); + + if (!result.success) { + result.error.errors.forEach(error => { + form.setError(error.path.join('.') as keyof zod.infer, { + type: 'manual', + message: error.message + }); + }); + + return; + } + + form.clearErrors(); + setValidDetails(true); + } + setActiveTab(tab); + }; + + const nestingStatus: ProgressStatus = activeTab === Tab.ORGANIZE ? 'in-progress' : 'not-started'; + + const detailsStatus: ProgressStatus = validDetails ? 'completed' : 'in-progress'; + + return ( + + + handleTabChange(tab as Tab)} + className="flex size-full flex-col" + > + +
+
+ + + {t('categories.create.tabs.details')} + + + {t('categories.create.tabs.organize')} + + +
+
+
+ + + + + + + + + + + + + {activeTab === Tab.ORGANIZE ? ( + + ) : ( + + )} + +
+
+
+ ); +}; diff --git a/src/routes/collections/collection-detail/collection-detail.tsx b/src/routes/collections/collection-detail/collection-detail.tsx index a9d1a923..a750bed1 100644 --- a/src/routes/collections/collection-detail/collection-detail.tsx +++ b/src/routes/collections/collection-detail/collection-detail.tsx @@ -5,8 +5,11 @@ import { SingleColumnPage } from "../../../components/layout/pages" import { useCollection } from "../../../hooks/api/collections" import { useExtension } from "../../../providers/extension-provider" import { CollectionGeneralSection } from "./components/collection-general-section" +import { CollectionMediaSection } from "./components/collection-media-section/collection-media-section" import { CollectionProductSection } from "./components/collection-product-section" import { collectionLoader } from "./loader" +import type { CollectionDetail as CollectionDetailType } from "./types" +import type { HttpTypes } from "@medusajs/types" export const CollectionDetail = () => { const initialData = useLoaderData() as Awaited< @@ -39,6 +42,13 @@ export const CollectionDetail = () => { data={collection} > + ) diff --git a/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx b/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx new file mode 100644 index 00000000..d52ba2be --- /dev/null +++ b/src/routes/collections/collection-detail/components/collection-media-section/collection-media-section.tsx @@ -0,0 +1,170 @@ +import type { HttpTypes } from "@medusajs/types" +import { PencilSquare, ThumbnailBadge } from "@medusajs/icons" +import { Container, Heading, Text, Tooltip } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + +import { ActionMenu } from "@components/common/action-menu" + +import type { CollectionDetail } from "../../types" +import { BannerIcon } from "@assets/icons/BannerIcon" + +type CollectionMediaSectionProps = { + collection: HttpTypes.AdminCollection & { + collection_detail?: CollectionDetail + } +} + +export const CollectionMediaSection = ({ collection }: CollectionMediaSectionProps) => { + const { t } = useTranslation() + + const iconId = collection.collection_detail?.icon_id + const iconUrl = collection.collection_detail?.media.find((m) => m.id === iconId)?.url + const thumbnailId = collection.collection_detail?.thumbnail_id + const bannerId = collection.collection_detail?.banner_id + const baseMedia = collection.collection_detail?.media.filter(item => item.url !== collection.collection_detail?.icon_id) ?? [] + + // If the icon is part of media, exclude it from the grid. + const media = iconId ? baseMedia.filter((m) => m.id !== iconId) : baseMedia + + return ( + <> + +
+ Media + , + }, + ], + }, + ]} + data-testid="collection-media-action-menu" + /> +
+ + {media.length > 0 ? ( +
+ {media.map((i, index) => { + const isThumbnail = !!thumbnailId && i.id === thumbnailId + const isBanner = !!bannerId && i.id === bannerId + + return ( +
+
+ {isThumbnail && ( +
+ + + +
+ )} + {isBanner && ( +
+ + + +
+ )} +
+ + + {i.alt_text + +
+ ) + })} +
+ ) : ( +
+
+ + {t("products.media.emptyState.header")} + + + Add media to showcase it on the storefront. + +
+
+ )} +
+ +
+ Icon + , + }, + ], + }, + ]} + data-testid="collection-media-action-menu" + /> +
+ {iconUrl ? ( +
+
+ Collection icon +
+
+ ) : ( +
+
+ + No icon yet + + + Add icon to showcase it near the collection label on the storefront. + +
+
+ )} +
+ + ) +} diff --git a/src/routes/collections/collection-detail/components/collection-media-section/index.ts b/src/routes/collections/collection-detail/components/collection-media-section/index.ts new file mode 100644 index 00000000..c8c3ba17 --- /dev/null +++ b/src/routes/collections/collection-detail/components/collection-media-section/index.ts @@ -0,0 +1 @@ +export * from "./collection-media-section" diff --git a/src/routes/collections/collection-detail/loader.ts b/src/routes/collections/collection-detail/loader.ts index e9225572..da11a626 100644 --- a/src/routes/collections/collection-detail/loader.ts +++ b/src/routes/collections/collection-detail/loader.ts @@ -1,12 +1,51 @@ import { LoaderFunctionArgs } from "react-router-dom" import { collectionsQueryKeys } from "../../../hooks/api/collections" +import { FetchError } from "@medusajs/js-sdk" import { sdk } from "../../../lib/client" import { queryClient } from "../../../lib/query-client" +type AdminCollectionDetailMedia = { + id: string + url: string + alt_text: string | null +} + +type AdminCollectionDetail = { + id: string + media: AdminCollectionDetailMedia[] + thumbnail_id: string | null + icon_id: string | null + banner_id: string | null + rank: number +} + const collectionDetailQuery = (id: string) => ({ queryKey: collectionsQueryKeys.detail(id), - queryFn: async () => sdk.admin.productCollection.retrieve(id), + queryFn: async () => { + const { collection } = await sdk.admin.productCollection.retrieve(id) + + try { + const { collection_detail } = await sdk.client.fetch<{ + collection_detail: AdminCollectionDetail + }>(`/admin/collections/${id}/details`, { + method: "GET", + }) + + return { + collection: { + ...collection, + collection_detail, + }, + } + } catch (error) { + if (error instanceof FetchError && error.status === 404) { + return { collection } + } + + throw error + } + }, }) export const collectionLoader = async ({ params }: LoaderFunctionArgs) => { diff --git a/src/routes/collections/collection-detail/types.ts b/src/routes/collections/collection-detail/types.ts new file mode 100644 index 00000000..74627f1e --- /dev/null +++ b/src/routes/collections/collection-detail/types.ts @@ -0,0 +1,16 @@ +export type CollectionDetail = { + id: string + media: Media[] + thumbnail_id: string | null + icon_id: string | null + banner_id: string | null + rank: number +} + +export type Media = { + id: string + url: string + alt_text: string | null + created_at?: Date + updated_at?: Date +} diff --git a/src/routes/collections/collection-media/collection-media.tsx b/src/routes/collections/collection-media/collection-media.tsx new file mode 100644 index 00000000..d8592c21 --- /dev/null +++ b/src/routes/collections/collection-media/collection-media.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import { RouteFocusModal } from "../../../components/modals" +import { useCollection } from "../../../hooks/api/collections" +import { CollectionMediaView } from "./components/collection-media-view" + +export const CollectionMedia = () => { + const { t } = useTranslation() + const { id } = useParams() + + const { collection, isLoading, isError, error } = useCollection(id!) + + const ready = !isLoading && collection + + if (isError) { + throw error + } + + return ( + + + {t("products.media.label")} + + + {t("products.media.editHint")} + + {ready && } + + ) +} + + diff --git a/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx b/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx new file mode 100644 index 00000000..f4131abb --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-gallery/collection-media-gallery.tsx @@ -0,0 +1,287 @@ +import type { HttpTypes } from '@medusajs/types'; +import { Button, clx, IconButton, Text, Tooltip } from '@medusajs/ui'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RouteFocusModal } from '@components/modals'; +import { + ArrowDownTray, + ThumbnailBadge, + Trash, + TriangleLeftMini, + TriangleRightMini +} from '@medusajs/icons'; +import { CollectionDetail } from '@routes/collections/collection-detail/types'; +import { useLocation, Link } from 'react-router-dom'; +import { usePostCollectionDetails } from '@hooks/api'; +import { BannerIcon } from '@assets/icons/BannerIcon'; + +type CollectionMediaGalleryProps = { + collection: HttpTypes.AdminCollection & { + collection_detail?: CollectionDetail; + }; +}; + +export const CollectionMediaGallery = ({ collection }: CollectionMediaGalleryProps) => { + const { t } = useTranslation(); + const { state } = useLocation(); + const [curr, setCurr] = useState(state?.curr || 0); + + const { mutate: postCollectionDetailsMutation } = usePostCollectionDetails(); + + const collectionDetail = collection?.collection_detail || { + media: [], + thumbnail_id: '', + icon_id: '', + banner_id: '' + }; + + const media = getMedia( + collectionDetail.media || [], + collectionDetail.thumbnail_id || null, + collectionDetail.banner_id || null, + collectionDetail.icon_id || null + ); + + const noMedia = !media.length; + + const handleDeleteCurrent = () => { + const idToDelete = collectionDetail.media[curr]?.id; + + if (!!idToDelete) { + const urlToDelete = idToDelete + ? collectionDetail.media.find(media => media.id === idToDelete)?.url + : ''; + + const isThumbnailToDelete = collectionDetail.thumbnail_id == urlToDelete; + const isIconToDelete = collectionDetail.icon_id == urlToDelete; + const isBannerToDelete = collectionDetail.banner_id == urlToDelete; + + postCollectionDetailsMutation({ + id: collection.id!, + payload: { + media: { delete: [idToDelete] }, + thumbnail: isThumbnailToDelete ? '' : collectionDetail.thumbnail_id, + icon: isIconToDelete ? '' : collectionDetail.icon_id, + banner: isBannerToDelete ? '' : collectionDetail.banner_id + } + }); + setCurr(0); + } + }; + + const handleDownloadCurrent = () => { + const a = document.createElement('a') as HTMLAnchorElement & { + download: string; + }; + + a.href = media[curr].url; + a.download = 'image'; + a.target = '_blank'; + + a.click(); + }; + + const next = useCallback(() => { + setCurr(prev => (prev + 1) % media.length); + }, [media]); + + const prev = useCallback(() => { + setCurr(prev => (prev - 1 + media.length) % media.length); + }, [media]); + + const goTo = useCallback((index: number) => { + setCurr(index); + }, []); + + return ( +
+ +
+ + + {t('products.media.deleteImageLabel')} + + + + {t('products.media.downloadImageLabel')} + + +
+
+ + + + +
+ ); +}; + +const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => { + const { t } = useTranslation(); + + if (media.length === 0) { + return ( +
+
+ + {t('products.media.emptyState.header')} + + + {t('products.media.emptyState.description')} + +
+ +
+ ); + } + + return ( +
+
+
+
+ {media[curr].isThumbnail && ( + + + + )} + {media[curr].isBanner && ( + + + + )} +
+ +
+
+
+ ); +}; + +const MAX_VISIBLE_ITEMS = 8; + +const Preview = ({ + media, + curr, + prev, + next, + goTo +}: { + media: Media[]; + curr: number; + prev: () => void; + next: () => void; + goTo: (index: number) => void; +}) => { + if (!media.length) { + return null; + } + + const getVisibleItems = (media: Media[], index: number) => { + if (media.length <= MAX_VISIBLE_ITEMS) { + return media; + } + + const half = Math.floor(MAX_VISIBLE_ITEMS / 2); + const start = (index - half + media.length) % media.length; + const end = (start + MAX_VISIBLE_ITEMS) % media.length; + + if (end < start) { + return [...media.slice(start), ...media.slice(0, end)]; + } else { + return media.slice(start, end); + } + }; + + const visibleItems = getVisibleItems(media, curr); + + return ( +
+ + + +
+ {visibleItems.map(item => { + const isCurrentImage = item.id === media[curr].id; + const originalIndex = media.findIndex(i => i.id === item.id); + + return ( + + ); + })} +
+ + + +
+ ); +}; + +type Media = { + id: string; + url: string; + isThumbnail?: boolean; + isBanner?: boolean; +}; + +const getMedia = ( + images: CollectionDetail['media'], + thumbnail: string | null, + banner: string | null, + icon: string | null +) => { + const media: Media[] = + images?.map(image => ({ + id: image.id, + url: image.url, + isThumbnail: image.id === thumbnail, + isBanner: image.id === banner + })) || []; + + if (thumbnail && !media.some(mediaItem => mediaItem.isThumbnail)) { + media.unshift({ + id: 'thumbnail_only', + url: thumbnail, + isThumbnail: true + }); + } + + if (banner && !media.some(mediaItem => mediaItem.isBanner)) { + media.unshift({ + id: 'banner_only', + url: banner, + isBanner: true + }); + } + + return media.filter(mediaItem => mediaItem.id !== icon); +}; diff --git a/src/routes/collections/collection-media/components/collection-media-gallery/index.ts b/src/routes/collections/collection-media/components/collection-media-gallery/index.ts new file mode 100644 index 00000000..7c9ac947 --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-gallery/index.ts @@ -0,0 +1 @@ +export * from "./collection-media-gallery" diff --git a/src/routes/collections/collection-media/components/collection-media-view/collection-media-view-context.tsx b/src/routes/collections/collection-media/components/collection-media-view/collection-media-view-context.tsx new file mode 100644 index 00000000..3ff62acc --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-view/collection-media-view-context.tsx @@ -0,0 +1,9 @@ +import { createContext } from "react" + +type CollectionMediaViewContextValue = { + goToGallery: () => void + goToEdit: () => void +} + +export const CollectionMediaViewContext = + createContext(null) diff --git a/src/routes/collections/collection-media/components/collection-media-view/collection-media-view.tsx b/src/routes/collections/collection-media/components/collection-media-view/collection-media-view.tsx new file mode 100644 index 00000000..079d8f78 --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-view/collection-media-view.tsx @@ -0,0 +1,58 @@ +import type { HttpTypes } from "@medusajs/types" +import { useSearchParams } from "react-router-dom" + +import { CollectionMediaGallery } from "../collection-media-gallery" +import { EditCollectionMediaForm } from "../edit-collection-media-form" +import { CollectionMediaViewContext } from "./collection-media-view-context" + +type CollectionMediaViewProps = { + collection: HttpTypes.AdminCollection +} + +enum View { + GALLERY = "gallery", + EDIT = "edit", +} + +const getView = (searchParams: URLSearchParams) => { + const view = searchParams.get("view") + + + if (view === View.EDIT) { + return View.EDIT + } + + return View.GALLERY +} + +export const CollectionMediaView = ({ collection }: CollectionMediaViewProps) => { + const [searchParams, setSearchParams] = useSearchParams() + const view = getView(searchParams) + const type = searchParams.get("type") + + const handleGoToView = (view: View) => { + return () => { + setSearchParams({ view }) + } + } + + return ( + + {renderView(view, collection, type)} + + ) +} + +const renderView = (view: View, collection: HttpTypes.AdminCollection, type: string | null) => { + switch (view) { + case View.GALLERY: + return + case View.EDIT: + return + } +} diff --git a/src/routes/collections/collection-media/components/collection-media-view/index.ts b/src/routes/collections/collection-media/components/collection-media-view/index.ts new file mode 100644 index 00000000..9c68d209 --- /dev/null +++ b/src/routes/collections/collection-media/components/collection-media-view/index.ts @@ -0,0 +1 @@ +export * from "./collection-media-view" diff --git a/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx b/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx new file mode 100644 index 00000000..c9c5390f --- /dev/null +++ b/src/routes/collections/collection-media/components/edit-collection-media-form/edit-collection-media-form.tsx @@ -0,0 +1,516 @@ +import type { HttpTypes } from "@medusajs/types" +import { + Button, + Checkbox, + clx, + CommandBar, + Tooltip, + toast, +} from "@medusajs/ui" +import { Fragment, useCallback, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + DropAnimation, + KeyboardSensor, + PointerSensor, + UniqueIdentifier, + defaultDropAnimationSideEffects, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { zodResolver } from "@hookform/resolvers/zod" +import { useFieldArray, useForm } from "react-hook-form" + +import { ThumbnailBadge } from "@medusajs/icons" + +import { BannerIcon } from "@assets/icons/BannerIcon" +import { RouteFocusModal, useRouteModal } from "@components/modals" +import { KeyboundForm } from "@components/utilities/keybound-form" +import { usePostCollectionDetails } from "@hooks/api" +import { sdk } from "@lib/client" +import { CollectionDetail } from "@routes/collections/collection-detail/types" +import { UploadMediaFormItem } from "@routes/products/common/components/upload-media-form-item" +import { EditCollectionMediaSchema } from "../../constants" +import { EditCollectionMediaSchemaType } from "../../types" + +type EditCollectionMediaFormProps = { + collection: HttpTypes.AdminCollection & { + collection_detail?: CollectionDetail + } + type: string | null +} + +type MediaField = EditCollectionMediaSchemaType["media"][number] & { + field_id: string + isBanner?: boolean +} + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + +export const EditCollectionMediaForm = ({ + collection, + type, +}: EditCollectionMediaFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const [activeId, setActiveId] = useState(null) + const [selection, setSelection] = useState>({}) + const [mediaToDelete, setMediaToDelete] = useState([]) + + const isIconType = type === "icon" + + const { mutateAsync: postCollectionDetailsMutation, isPending } = + usePostCollectionDetails() + + const thumbnailId = collection.collection_detail?.thumbnail_id ?? null + const bannerId = collection.collection_detail?.banner_id ?? null + const iconId = collection.collection_detail?.icon_id ?? null + + const iconMedia = useMemo(() => { + if (!iconId) { + return undefined + } + + return collection.collection_detail?.media?.find((m) => m.id === iconId) + }, [collection.collection_detail?.media, iconId]) + + const defaultMedia: EditCollectionMediaSchemaType["media"] = useMemo(() => { + if (isIconType) { + return iconMedia + ? [ + { + id: iconMedia.id, + url: iconMedia.url, + isThumbnail: false, + file: null, + }, + ] + : [] + } + + return (collection.collection_detail?.media ?? []) + .filter((m) => m.id !== iconId) + .map((m) => ({ + id: m.id, + url: m.url, + isThumbnail: m.id === thumbnailId, + file: null, + })) + }, [bannerId, collection.collection_detail?.media, iconId, iconMedia, isIconType, thumbnailId]) + + const form = useForm({ + defaultValues: { + media: defaultMedia, + }, + resolver: zodResolver(EditCollectionMediaSchema), + }) + + const { fields, append, remove } = useFieldArray({ + name: "media", + control: form.control, + keyName: "field_id", + }) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id) + } + + const handleDragEnd = (event: DragEndEvent) => { + setActiveId(null) + const { active, over } = event + + if (active.id !== over?.id) { + const oldIndex = fields.findIndex((item) => item.field_id === active.id) + const newIndex = fields.findIndex((item) => item.field_id === over?.id) + + form.setValue("media", arrayMove(fields, oldIndex, newIndex), { + shouldDirty: true, + shouldTouch: true, + }) + } + } + + const handleDragCancel = () => { + setActiveId(null) + } + + const handleCheckedChange = useCallback( + (id: string) => { + return (val: boolean) => { + if (!val) { + const { [id]: _, ...rest } = selection + setSelection(rest) + } else { + setSelection((prev) => ({ ...prev, [id]: true })) + } + } + }, + [selection] + ) + + const handleDelete = () => { + const ids = Object.keys(selection) + const indices = ids.map((id) => fields.findIndex((m) => m.id === id)) + + setMediaToDelete(ids) + remove(indices) + setSelection({}) + } + + const handlePromoteToThumbnail = () => { + const id = Object.keys(selection)[0] + + postCollectionDetailsMutation({ + id: collection.id, + payload: { media: { delete: [], create: [] }, thumbnail: id }, + }) + setSelection({}) + } + + const handlePromoteToBanner = () => { + const id = Object.keys(selection)[0] + + postCollectionDetailsMutation({ + id: collection.id, + payload: { media: { delete: [], create: [] }, banner: id }, + }) + setSelection({}) + } + + const selectionCount = Object.keys(selection).length + + const handleSubmit = form.handleSubmit(async ({ media }) => { + const filesToUpload = !isIconType + ? media + .map((m, i) => ({ file: m.file, index: i })) + .filter((m) => !!m.file) + : [ + { + file: [...media].reverse().find((m) => !!m.file)?.file, + index: 0, + }, + ].filter((m) => !!m.file) + + let uploaded: HttpTypes.AdminFile[] = [] + + if (filesToUpload.length) { + const { files: uploads } = await sdk.admin.upload + .create({ files: filesToUpload.map((m) => m.file) }) + .catch(() => { + form.setError("media", { + type: "invalid_file", + message: t("products.media.failedToUpload"), + }) + return { files: [] } + }) + + uploaded = uploads + } + + const mediaToCreate = uploaded.map((item) => ({ url: item.url, alt_text: "" })) + + if (isIconType) { + const deleteIds = iconMedia?.id ? [iconMedia.id] : [] + let payload = { + media: { delete: deleteIds, create: [] }, + } as { + media: { delete: string[]; create: { url: string; alt_text?: string }[] }; + icon?: { url: string} | string |null; + }; + + if (mediaToCreate[0]?.url) { + payload = { ...payload, icon: { url: mediaToCreate[0]?.url } } + } + + await postCollectionDetailsMutation( + { + id: collection.id, + payload, + }, + { + onSuccess: () => { + toast.success(t("products.media.successToast")) + handleSuccess(`/collections/${collection.id}`) + }, + onError: (error: any) => { + toast.error(error.message) + }, + } as any + ) + return + } + + await postCollectionDetailsMutation( + { + id: collection.id, + payload: { + media: { delete: mediaToDelete, create: mediaToCreate }, + }, + }, + { + onSuccess: () => { + toast.success(t("products.media.successToast")) + handleSuccess(`/collections/${collection.id}`) + }, + onError: (error: any) => { + toast.error(error.message) + }, + } as any + ) + }) + + return ( + + + +
+ +
+
+ + +
+ +
+
+ m.field_id)} strategy={rectSortingStrategy}> + {fields.map((m) => { + const isBanner = !!bannerId && m.id === bannerId + + return ( + + ) + })} + + + + {activeId ? ( + m.field_id === activeId)! as any), + isBanner: !!bannerId && fields.find((m) => m.field_id === activeId)?.id === bannerId, + }} + checked={ + !!selection[fields.find((m) => m.field_id === activeId)!.id!] + } + /> + ) : null} + +
+
+
+ +
+ +
+
+
+ + + + + {t("general.countSelected", { count: selectionCount })} + + + {!isIconType && selectionCount === 1 && ( + <> + + + + + + + + + + )} + + + + + +
+ + + + +
+
+
+
+ ) +} + +const MediaGridItem = ({ + media, + checked, + onCheckedChange, +}: { + media: MediaField + checked: boolean + onCheckedChange: (value: boolean) => void +}) => { + const { t } = useTranslation() + + const handleToggle = useCallback( + (value: boolean) => { + onCheckedChange(value) + }, + [onCheckedChange] + ) + + const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = + useSortable({ id: media.field_id }) + + const style = { + opacity: isDragging ? 0.4 : undefined, + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ {(media.isThumbnail || media.isBanner) && ( +
+ {media.isThumbnail && ( + + + + )} + {media.isBanner && ( + + + + )} +
+ )} + +
+ +
+ { + e.stopPropagation() + }} + checked={checked} + onCheckedChange={handleToggle} + /> +
+ + +
+ ) +} + +export const MediaGridItemOverlay = ({ + media, + checked, +}: { + media: MediaField + checked: boolean +}) => { + return ( +
+ {(media.isThumbnail || media.isBanner) && ( +
+ {media.isThumbnail && } + {media.isBanner && } +
+ )} + +
+ +
+ + +
+ ) +} + diff --git a/src/routes/collections/collection-media/components/edit-collection-media-form/index.ts b/src/routes/collections/collection-media/components/edit-collection-media-form/index.ts new file mode 100644 index 00000000..7c8b50e7 --- /dev/null +++ b/src/routes/collections/collection-media/components/edit-collection-media-form/index.ts @@ -0,0 +1 @@ +export { EditCollectionMediaForm } from "./edit-collection-media-form" diff --git a/src/routes/collections/collection-media/constants.ts b/src/routes/collections/collection-media/constants.ts new file mode 100644 index 00000000..103c0181 --- /dev/null +++ b/src/routes/collections/collection-media/constants.ts @@ -0,0 +1,6 @@ +import { z } from "zod" +import { MediaSchema } from "@routes/products/product-create/constants" + +export const EditCollectionMediaSchema = z.object({ + media: z.array(MediaSchema), + }) \ No newline at end of file diff --git a/src/routes/collections/collection-media/index.ts b/src/routes/collections/collection-media/index.ts new file mode 100644 index 00000000..e406335b --- /dev/null +++ b/src/routes/collections/collection-media/index.ts @@ -0,0 +1 @@ +export { CollectionMedia as Component } from "./collection-media" diff --git a/src/routes/collections/collection-media/types.ts b/src/routes/collections/collection-media/types.ts new file mode 100644 index 00000000..6e531a47 --- /dev/null +++ b/src/routes/collections/collection-media/types.ts @@ -0,0 +1,5 @@ +import { z } from "zod" +import { EditCollectionMediaSchema } from "@routes/collections/collection-media/constants" + + +export type EditCollectionMediaSchemaType = z.infer diff --git a/src/routes/collections/common/components/collection-tree/collection-tree.tsx b/src/routes/collections/common/components/collection-tree/collection-tree.tsx new file mode 100644 index 00000000..19adfd3b --- /dev/null +++ b/src/routes/collections/common/components/collection-tree/collection-tree.tsx @@ -0,0 +1,53 @@ +import { UniqueIdentifier } from '@dnd-kit/core'; +import { ReactNode } from 'react'; +import { SortableTree } from '@components/common/sortable-tree'; +import { CollectionTreeItem } from '../../types'; + +export const CollectionTree = ({ + value, + isLoading = false, + onChange, + enableDrag = true, + renderValue +}: { + value: CollectionTreeItem[]; + isLoading: boolean; + onChange: ( + value: { + id: UniqueIdentifier; + parentId: UniqueIdentifier | null; + index: number; + }, + items: CollectionTreeItem[] + ) => void; + enableDrag?: boolean | ((item: CollectionTreeItem) => boolean); + renderValue: (item: CollectionTreeItem) => ReactNode; +}) => { + if (isLoading) { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ ); + } + + return ( + + ); +}; + +const CollectionLeafPlaceholder = () => { + return ( +
+ ); +}; diff --git a/src/routes/collections/common/types.ts b/src/routes/collections/common/types.ts new file mode 100644 index 00000000..8e22a47e --- /dev/null +++ b/src/routes/collections/common/types.ts @@ -0,0 +1,5 @@ +export type CollectionTreeItem = { + id: string + title: string + rank: number | null +} \ No newline at end of file diff --git a/src/routes/collections/common/utils.ts b/src/routes/collections/common/utils.ts new file mode 100644 index 00000000..0b14d7e0 --- /dev/null +++ b/src/routes/collections/common/utils.ts @@ -0,0 +1,20 @@ +import { CollectionTreeItem } from './types'; + +export const insertCollectionTreeItem = ( + collections: CollectionTreeItem[], + newItem: CollectionTreeItem +): CollectionTreeItem[] => { + const withoutNewItem = collections.filter((c) => c.id !== newItem.id); + + const targetIndexRaw = newItem.rank ?? 0; + const targetIndex = Math.max(0, Math.min(targetIndexRaw, withoutNewItem.length)); + + const next = [...withoutNewItem]; + next.splice(targetIndex, 0, newItem); + + next.forEach((item, index) => { + item.rank = index; + }); + + return next; +}; diff --git a/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx b/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx index 2d23cb8f..f676c87a 100644 --- a/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx +++ b/src/routes/products/common/components/upload-media-form-item/upload-media-form-item.tsx @@ -37,12 +37,14 @@ export const UploadMediaFormItem = ({ form, append, showHint = true, + multiple = true, }: { form: | UseFormReturn | UseFormReturn append: (value: Media) => void - showHint?: boolean + showHint?: boolean, + multiple?: boolean }) => { const { t } = useTranslation() @@ -104,6 +106,7 @@ export const UploadMediaFormItem = ({ hasError={!!form.formState.errors.media} formats={SUPPORTED_FORMATS} onUploaded={onUploaded} + multiple={multiple} />