feat: Add support for uploading media when creating a product (#7567)
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
export * from "./media-grid-view"
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
|
||||
import { Tooltip, clx } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface MediaView {
|
||||
id?: string
|
||||
field_id: string
|
||||
url: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
interface MediaGridProps {
|
||||
media: MediaView[]
|
||||
selection: Record<string, boolean>
|
||||
onCheckedChange: (id: string) => (value: boolean) => void
|
||||
}
|
||||
|
||||
export const MediaGrid = ({
|
||||
media,
|
||||
selection,
|
||||
onCheckedChange,
|
||||
}: MediaGridProps) => {
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle size-full overflow-auto">
|
||||
<div className="grid h-fit auto-rows-auto grid-cols-4 gap-6 p-6">
|
||||
{media.map((m) => {
|
||||
return (
|
||||
<MediaGridItem
|
||||
onCheckedChange={onCheckedChange(m.id!)}
|
||||
checked={!!selection[m.id!]}
|
||||
key={m.field_id}
|
||||
media={m}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MediaGridItemProps {
|
||||
media: MediaView
|
||||
checked: boolean
|
||||
onCheckedChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
const MediaGridItem = ({
|
||||
media,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: MediaGridItemProps) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onCheckedChange(!checked)
|
||||
}, [checked, onCheckedChange])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full overflow-hidden rounded-lg outline-none"
|
||||
>
|
||||
{media.isThumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clx(
|
||||
"transition-fg absolute right-2 top-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100",
|
||||
{
|
||||
"opacity-100": checked,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"group relative inline-flex h-4 w-4 items-center justify-center outline-none "
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"text-ui-fg-on-inverted bg-ui-bg-component shadow-borders-base [&_path]:shadow-details-contrast-on-bg-interactive group-disabled:text-ui-fg-disabled group-disabled:!bg-ui-bg-disabled group-disabled:!shadow-borders-base transition-fg h-[14px] w-[14px] rounded-[3px]",
|
||||
{
|
||||
"bg-ui-bg-interactive group-hover:bg-ui-bg-interactive shadow-borders-interactive-with-shadow":
|
||||
checked,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<div className="absolute inset-0">
|
||||
<CheckMini />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.5 } }}
|
||||
className="bg-ui-bg-subtle-hover absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Spinner className="text-ui-fg-subtle animate-spin" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<img
|
||||
src={media.url}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
alt=""
|
||||
className="size-full object-cover object-center"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./upload-media-form-item"
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import {
|
||||
FileType,
|
||||
FileUpload,
|
||||
} from "../../../../../components/common/file-upload"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import {
|
||||
EditProductMediaSchemaType,
|
||||
ProductCreateSchemaType,
|
||||
} from "../../../product-create/types"
|
||||
import { MediaSchema } from "../../../product-create/constants"
|
||||
import { z } from "zod"
|
||||
|
||||
type Media = z.infer<typeof MediaSchema>
|
||||
|
||||
const SUPPORTED_FORMATS = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
const SUPPORTED_FORMATS_FILE_EXTENSIONS = [
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".heic",
|
||||
".svg",
|
||||
]
|
||||
|
||||
export const UploadMediaFormItem = ({
|
||||
form,
|
||||
append,
|
||||
}: {
|
||||
form:
|
||||
| UseFormReturn<ProductCreateSchemaType>
|
||||
| UseFormReturn<EditProductMediaSchemaType>
|
||||
append: (value: Media) => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const hasInvalidFiles = (fileList: FileType[]) => {
|
||||
const invalidFile = fileList.find(
|
||||
(f) => !SUPPORTED_FORMATS.includes(f.file.type)
|
||||
)
|
||||
|
||||
if (invalidFile) {
|
||||
form.setError("media", {
|
||||
type: "invalid_file",
|
||||
message: t("products.media.invalidFileType", {
|
||||
name: invalidFile.file.name,
|
||||
types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
|
||||
}),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
control={
|
||||
form.control as UseFormReturn<EditProductMediaSchemaType>["control"]
|
||||
}
|
||||
name="media"
|
||||
render={() => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Form.Label optional>{t("products.media.label")}</Form.Label>
|
||||
<Form.Hint>{t("products.media.editHint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<FileUpload
|
||||
label={t("products.media.uploadImagesLabel")}
|
||||
hint={t("products.media.uploadImagesHint")}
|
||||
hasError={!!form.formState.errors.media}
|
||||
formats={SUPPORTED_FORMATS}
|
||||
onUploaded={(files) => {
|
||||
form.clearErrors("media")
|
||||
if (hasInvalidFiles(files)) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: For now all files that get uploaded are not thumbnails, revisit this logic
|
||||
files.forEach((f) => append({ ...f, isThumbnail: false }))
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+60
-49
@@ -1,69 +1,80 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { CommandBar, Heading } from "@medusajs/ui"
|
||||
import { UseFormReturn, useFieldArray } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { FileUpload } from "../../../../../../../components/common/file-upload"
|
||||
import { Form } from "../../../../../../../components/common/form"
|
||||
import { ProductCreateSchemaType } from "../../../../types"
|
||||
import { MediaGrid } from "../../../../../common/components/media-grid-view"
|
||||
import { useCallback, useState } from "react"
|
||||
import { UploadMediaFormItem } from "../../../../../common/components/upload-media-form-item"
|
||||
|
||||
type ProductCreateMediaSectionProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
}
|
||||
|
||||
const SUPPORTED_FORMATS = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
export const ProductCreateMediaSection = ({
|
||||
form,
|
||||
}: ProductCreateMediaSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [selection, setSelection] = useState<Record<string, true>>({})
|
||||
const selectionCount = Object.keys(selection).length
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: "media",
|
||||
control: form.control,
|
||||
keyName: "field_id",
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
const ids = Object.keys(selection)
|
||||
const indices = ids.map((id) => fields.findIndex((m) => m.id === id))
|
||||
|
||||
remove(indices)
|
||||
setSelection({})
|
||||
}
|
||||
|
||||
const handleCheckedChange = useCallback(
|
||||
(id: string) => {
|
||||
return (val: boolean) => {
|
||||
if (!val) {
|
||||
const { [id]: _, ...rest } = selection
|
||||
setSelection(rest)
|
||||
} else {
|
||||
setSelection((prev) => ({ ...prev, [id]: true }))
|
||||
}
|
||||
}
|
||||
},
|
||||
[selection]
|
||||
)
|
||||
|
||||
return (
|
||||
<div id="media" className="flex flex-col gap-y-8">
|
||||
<Heading level="h2">{t("products.media.label")}</Heading>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="images"
|
||||
render={() => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Form.Label optional>
|
||||
{t("products.media.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>{t("products.media.editHint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<FileUpload
|
||||
label={t("products.media.uploadImagesLabel")}
|
||||
hint={t("products.media.uploadImagesHint")}
|
||||
hasError={!!form.formState.errors.images}
|
||||
formats={SUPPORTED_FORMATS}
|
||||
onUploaded={(files) => {
|
||||
form.clearErrors("images")
|
||||
// if (hasInvalidFiles(files)) {
|
||||
// return
|
||||
// }
|
||||
|
||||
// files.forEach((f) => append(f))
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<UploadMediaFormItem form={form} append={append} />
|
||||
</div>
|
||||
{fields?.length ? (
|
||||
<MediaGrid
|
||||
media={fields}
|
||||
selection={selection}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CommandBar open={!!selectionCount}>
|
||||
<CommandBar.Bar>
|
||||
<CommandBar.Value>
|
||||
{t("general.countSelected", {
|
||||
count: selectionCount,
|
||||
})}
|
||||
</CommandBar.Value>
|
||||
<CommandBar.Seperator />
|
||||
|
||||
<CommandBar.Command
|
||||
action={handleDelete}
|
||||
label={t("actions.delete")}
|
||||
shortcut="d"
|
||||
/>
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -6,6 +6,7 @@ import { Divider } from "../../../../../components/common/divider"
|
||||
import { ProductCreateSchemaType } from "../../types"
|
||||
import { ProductCreateGeneralSection } from "./components/product-create-details-general-section"
|
||||
import { ProductCreateVariantsSection } from "./components/product-create-details-variant-section"
|
||||
import { ProductCreateMediaSection } from "./components/product-create-details-media-section"
|
||||
|
||||
type ProductAttributesProps = {
|
||||
form: UseFormReturn<ProductCreateSchemaType>
|
||||
@@ -19,6 +20,8 @@ export const ProductCreateDetailsForm = ({ form }: ProductAttributesProps) => {
|
||||
<ProductCreateGeneralSection form={form} />
|
||||
<Divider />
|
||||
<ProductCreateVariantsSection form={form} />
|
||||
<Divider />
|
||||
<ProductCreateMediaSection form={form} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+31
-1
@@ -19,6 +19,8 @@ import { ProductCreateOrganizeForm } from "../product-create-organize-form"
|
||||
import { ProductCreateInventoryKitForm } from "../product-create-inventory-kit-form"
|
||||
import { ProductCreateVariantsForm } from "../product-create-variants-form"
|
||||
import { isFetchError } from "../../../../../lib/is-fetch-error"
|
||||
import { sdk } from "../../../../../lib/client"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
enum Tab {
|
||||
DETAILS = "details",
|
||||
@@ -80,13 +82,41 @@ export const ProductCreateForm = () => {
|
||||
|
||||
const isDraftSubmission = submitter.dataset.name === SAVE_DRAFT_BUTTON
|
||||
|
||||
const payload = { ...values }
|
||||
const media = values.media || []
|
||||
const payload = { ...values, media: undefined }
|
||||
|
||||
let uploadedMedia: (HttpTypes.AdminFile & { isThumbnail: boolean })[] = []
|
||||
try {
|
||||
if (media.length) {
|
||||
const thumbnailReq = media.find((m) => m.isThumbnail)
|
||||
const otherMediaReq = media.filter((m) => !m.isThumbnail)
|
||||
|
||||
const fileReqs = []
|
||||
if (thumbnailReq) {
|
||||
fileReqs.push(
|
||||
sdk.admin.uploads
|
||||
.create({ files: [thumbnailReq.file] })
|
||||
.then((r) => r.files.map((f) => ({ ...f, isThumbnail: true })))
|
||||
)
|
||||
}
|
||||
if (otherMediaReq?.length) {
|
||||
fileReqs.push(
|
||||
sdk.admin.uploads
|
||||
.create({
|
||||
files: otherMediaReq.map((m) => m.file),
|
||||
})
|
||||
.then((r) => r.files.map((f) => ({ ...f, isThumbnail: false })))
|
||||
)
|
||||
}
|
||||
|
||||
uploadedMedia = (await Promise.all(fileReqs)).flat()
|
||||
}
|
||||
|
||||
const { product } = await mutateAsync(
|
||||
normalizeProductFormValues({
|
||||
// TODO: workflow should handle inventory creation
|
||||
...payload,
|
||||
media: uploadedMedia,
|
||||
status: (isDraftSubmission ? "draft" : "published") as any,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -2,6 +2,13 @@ import { z } from "zod"
|
||||
import { decorateVariantsWithDefaultValues } from "./utils.ts"
|
||||
import { optionalInt } from "../../../lib/validation.ts"
|
||||
|
||||
export const MediaSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
url: z.string(),
|
||||
isThumbnail: z.boolean(),
|
||||
file: z.any().nullable(), // File
|
||||
})
|
||||
|
||||
export const ProductCreateSchema = z
|
||||
.object({
|
||||
title: z.string().min(1),
|
||||
@@ -69,8 +76,7 @@ export const ProductCreateSchema = z
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
images: z.array(z.string()).optional(),
|
||||
thumbnail: z.string().optional(),
|
||||
media: z.array(MediaSchema).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.variants.every((v) => !v.should_create)) {
|
||||
@@ -82,6 +88,10 @@ export const ProductCreateSchema = z
|
||||
}
|
||||
})
|
||||
|
||||
export const EditProductMediaSchema = z.object({
|
||||
media: z.array(MediaSchema),
|
||||
})
|
||||
|
||||
export const PRODUCT_CREATE_FORM_DEFAULTS: Partial<
|
||||
z.infer<typeof ProductCreateSchema>
|
||||
> = {
|
||||
@@ -107,8 +117,7 @@ export const PRODUCT_CREATE_FORM_DEFAULTS: Partial<
|
||||
},
|
||||
]),
|
||||
enable_variants: false,
|
||||
images: [],
|
||||
thumbnail: "",
|
||||
media: [],
|
||||
categories: [],
|
||||
collection_id: "",
|
||||
description: "",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from "zod"
|
||||
import { ProductCreateSchema } from "./constants"
|
||||
import { EditProductMediaSchema, ProductCreateSchema } from "./constants"
|
||||
|
||||
export type ProductCreateSchemaType = z.infer<typeof ProductCreateSchema>
|
||||
|
||||
export type EditProductMediaSchemaType = z.infer<typeof EditProductMediaSchema>
|
||||
|
||||
@@ -6,6 +6,11 @@ import { castNumber } from "../../../lib/cast-number.ts"
|
||||
export const normalizeProductFormValues = (
|
||||
values: ProductCreateSchemaType & { status: CreateProductDTO["status"] }
|
||||
) => {
|
||||
const thumbnail = values.media?.find((media) => media.isThumbnail)?.url
|
||||
const images = values.media
|
||||
?.filter((media) => !media.isThumbnail)
|
||||
.map((media) => ({ url: media.url }))
|
||||
|
||||
return {
|
||||
status: values.status,
|
||||
is_giftcard: false,
|
||||
@@ -15,9 +20,7 @@ export const normalizeProductFormValues = (
|
||||
sales_channels: values?.sales_channels?.length
|
||||
? values.sales_channels?.map((sc) => ({ id: sc.id }))
|
||||
: undefined,
|
||||
images: values.images?.length
|
||||
? values.images.map((url) => ({ url }))
|
||||
: undefined,
|
||||
images,
|
||||
collection_id: values.collection_id || undefined,
|
||||
categories: values.categories.map((id) => ({ id })),
|
||||
type_id: values.type_id || undefined,
|
||||
@@ -26,7 +29,7 @@ export const normalizeProductFormValues = (
|
||||
material: values.material || undefined,
|
||||
mid_code: values.mid_code || undefined,
|
||||
hs_code: values.hs_code || undefined,
|
||||
thumbnail: values.thumbnail || undefined,
|
||||
thumbnail,
|
||||
title: values.title,
|
||||
subtitle: values.subtitle || undefined,
|
||||
description: values.description || undefined,
|
||||
|
||||
+41
-221
@@ -1,67 +1,38 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
|
||||
import { Image, Product } from "@medusajs/medusa"
|
||||
import { Button, CommandBar, Tooltip, clx, toast } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { Button, CommandBar } from "@medusajs/ui"
|
||||
import { Fragment, useCallback, useState } from "react"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
FileType,
|
||||
FileUpload,
|
||||
} from "../../../../../components/common/file-upload"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { sdk } from "../../../../../lib/client"
|
||||
import {
|
||||
EditProductMediaSchema,
|
||||
MediaSchema,
|
||||
} from "../../../product-create/constants"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { MediaGrid } from "../../../common/components/media-grid-view"
|
||||
import { UploadMediaFormItem } from "../../../common/components/upload-media-form-item"
|
||||
import { EditProductMediaSchemaType } from "../../../product-create/types"
|
||||
|
||||
type ProductMediaViewProps = {
|
||||
product: Product
|
||||
product: HttpTypes.AdminProduct
|
||||
}
|
||||
|
||||
const MediaSchema = z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
isThumbnail: z.boolean(),
|
||||
file: z.any().nullable(), // File
|
||||
})
|
||||
|
||||
const SUPPORTED_FORMATS = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
const SUPPORTED_FORMATS_FILE_EXTENSIONS = [
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".heic",
|
||||
".svg",
|
||||
]
|
||||
|
||||
type Media = z.infer<typeof MediaSchema>
|
||||
|
||||
const EditProductMediaSchema = z.object({
|
||||
media: z.array(MediaSchema),
|
||||
})
|
||||
|
||||
export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
const [selection, setSelection] = useState<Record<string, true>>({})
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<z.infer<typeof EditProductMediaSchema>>({
|
||||
const form = useForm<EditProductMediaSchemaType>({
|
||||
defaultValues: {
|
||||
media: getDefaultValues(product.images, product.thumbnail),
|
||||
},
|
||||
@@ -74,47 +45,40 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
keyName: "field_id",
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useUpdateProduct(product.id)
|
||||
const { mutateAsync, isPending } = useUpdateProduct(product.id!)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async ({ media }) => {
|
||||
const urls = media.map((m) => m.url)
|
||||
|
||||
const filesToUpload = media
|
||||
.map((m, index) => ({ file: m.file, index }))
|
||||
.filter((m) => m.file)
|
||||
.map((m, i) => ({ file: m.file, index: i }))
|
||||
.filter((m) => !!m.file)
|
||||
|
||||
let uploaded: HttpTypes.AdminFile[] = []
|
||||
|
||||
if (filesToUpload.length) {
|
||||
const files = filesToUpload.map((m) => m.file) as File[]
|
||||
|
||||
const uploads = await sdk.admin.uploads
|
||||
.create({ files })
|
||||
.then((res) => {
|
||||
return res.files
|
||||
})
|
||||
const { files: uploads } = await sdk.admin.uploads
|
||||
.create({ files: filesToUpload.map((m) => m.file) })
|
||||
.catch(() => {
|
||||
form.setError("media", {
|
||||
type: "invalid_file",
|
||||
message: t("products.media.failedToUpload"),
|
||||
})
|
||||
return { files: [] }
|
||||
})
|
||||
|
||||
if (!uploads) {
|
||||
return
|
||||
}
|
||||
|
||||
// Insert the URLs of the uploaded files back into the urls array
|
||||
uploads.forEach((upload, i) => {
|
||||
const originalIndex = filesToUpload[i].index
|
||||
urls[originalIndex] = upload.url
|
||||
})
|
||||
uploaded = uploads
|
||||
}
|
||||
|
||||
const thumbnailIndex = media.findIndex((m) => m.isThumbnail)
|
||||
const thumbnail = thumbnailIndex > -1 ? urls[thumbnailIndex] : null
|
||||
const withUpdatedUrls = media.map((entry, i) => {
|
||||
const toUploadIndex = filesToUpload.findIndex((m) => m.index === i)
|
||||
if (toUploadIndex > -1) {
|
||||
return { ...entry, url: uploaded[toUploadIndex]?.url }
|
||||
}
|
||||
return entry
|
||||
})
|
||||
const thumbnail = withUpdatedUrls.find((m) => m.isThumbnail)?.url
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
images: urls.map((url) => ({ url })),
|
||||
images: withUpdatedUrls.map((file) => ({ url: file.url })),
|
||||
// Set thumbnail to empty string if no thumbnail is selected, as the API does not accept null
|
||||
thumbnail: thumbnail || "",
|
||||
},
|
||||
@@ -126,26 +90,6 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
)
|
||||
})
|
||||
|
||||
const hasInvalidFiles = (fileList: FileType[]) => {
|
||||
const invalidFile = fileList.find(
|
||||
(f) => !SUPPORTED_FORMATS.includes(f.file.type)
|
||||
)
|
||||
|
||||
if (invalidFile) {
|
||||
form.setError("media", {
|
||||
type: "invalid_file",
|
||||
message: t("products.media.invalidFileType", {
|
||||
name: invalidFile.file.name,
|
||||
types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
|
||||
}),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const handleCheckedChange = useCallback(
|
||||
(id: string) => {
|
||||
return (val: boolean) => {
|
||||
@@ -221,58 +165,13 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
|
||||
<div className="flex size-full flex-col-reverse lg:grid lg:grid-cols-[1fr_560px]">
|
||||
<div className="bg-ui-bg-subtle size-full overflow-auto">
|
||||
<div className="grid h-fit auto-rows-auto grid-cols-4 gap-6 p-6">
|
||||
{fields.map((m) => {
|
||||
return (
|
||||
<GridItem
|
||||
onCheckedChange={handleCheckedChange(m.id)}
|
||||
checked={!!selection[m.id]}
|
||||
key={m.field_id}
|
||||
media={m}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<MediaGrid
|
||||
media={fields}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
selection={selection}
|
||||
/>
|
||||
<div className="bg-ui-bg-base border-b px-6 py-4 lg:border-b-0 lg:border-l">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="media"
|
||||
render={() => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Form.Label optional>
|
||||
{t("products.media.label")}
|
||||
</Form.Label>
|
||||
<Form.Hint>{t("products.media.editHint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<FileUpload
|
||||
label={t("products.media.uploadImagesLabel")}
|
||||
hint={t("products.media.uploadImagesHint")}
|
||||
hasError={!!form.formState.errors.media}
|
||||
formats={SUPPORTED_FORMATS}
|
||||
onUploaded={(files) => {
|
||||
form.clearErrors("media")
|
||||
if (hasInvalidFiles(files)) {
|
||||
return
|
||||
}
|
||||
|
||||
files.forEach((f) =>
|
||||
append({ ...f, isThumbnail: false })
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<UploadMediaFormItem form={form} append={append} />
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Body>
|
||||
@@ -306,93 +205,14 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const GridItem = ({
|
||||
media,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
media: Media
|
||||
checked: boolean
|
||||
onCheckedChange: (value: boolean) => void
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onCheckedChange(!checked)
|
||||
}, [checked, onCheckedChange])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="shadow-elevation-card-rest hover:shadow-elevation-card-hover focus-visible:shadow-borders-focus bg-ui-bg-subtle-hover group relative aspect-square h-auto max-w-full overflow-hidden rounded-lg outline-none"
|
||||
>
|
||||
{media.isThumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clx(
|
||||
"transition-fg absolute right-2 top-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100",
|
||||
{
|
||||
"opacity-100": checked,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"group relative inline-flex h-5 w-5 items-center justify-center outline-none "
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"text-ui-fg-on-inverted bg-ui-bg-component shadow-borders-base [&_path]:shadow-details-contrast-on-bg-interactive group-disabled:text-ui-fg-disabled group-disabled:!bg-ui-bg-disabled group-disabled:!shadow-borders-base transition-fg h-[14px] w-[14px] rounded-[3px]",
|
||||
{
|
||||
"bg-ui-bg-interactive group-hover:bg-ui-bg-interactive shadow-borders-interactive-with-shadow":
|
||||
checked,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<div className="absolute inset-0">
|
||||
<CheckMini />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.5 } }}
|
||||
className="bg-ui-bg-subtle-hover absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Spinner className="text-ui-fg-subtle animate-spin" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<img
|
||||
src={media.url}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
alt=""
|
||||
className="size-full object-cover object-center"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultValues = (images: Image[] | null, thumbnail: string | null) => {
|
||||
const getDefaultValues = (
|
||||
images: HttpTypes.AdminProductImage[] | undefined,
|
||||
thumbnail: string | undefined
|
||||
) => {
|
||||
const media: Media[] =
|
||||
images?.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
id: image.id!,
|
||||
url: image.url!,
|
||||
isThumbnail: image.url === thumbnail,
|
||||
file: null,
|
||||
})) || []
|
||||
|
||||
+3
-4
@@ -1,12 +1,11 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { EditProductMediaForm } from "../edit-product-media-form"
|
||||
import { ProductMediaGallery } from "../product-media-gallery"
|
||||
import { ProductMediaViewContext } from "./product-media-view-context"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type ProductMediaViewProps = {
|
||||
product: Product
|
||||
product: HttpTypes.AdminProduct
|
||||
}
|
||||
|
||||
enum View {
|
||||
@@ -45,7 +44,7 @@ export const ProductMediaView = ({ product }: ProductMediaViewProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const renderView = (view: View, product: Product) => {
|
||||
const renderView = (view: View, product: HttpTypes.AdminProduct) => {
|
||||
switch (view) {
|
||||
case View.GALLERY:
|
||||
return <ProductMediaGallery product={product} />
|
||||
|
||||
Reference in New Issue
Block a user