feat: Add support for uploading media in admin (#7564)
This commit is contained in:
@@ -210,6 +210,7 @@
|
||||
"uploadImagesLabel": "Upload images",
|
||||
"uploadImagesHint": "Drag and drop images here or click to upload.",
|
||||
"invalidFileType": "'{{name}}' is not a supported file type. Supported file types are: {{types}}.",
|
||||
"failedToUpload": "Failed to upload the added media. Please try again.",
|
||||
"deleteWarning_one": "You are about to delete {{count}} image. This action cannot be undone.",
|
||||
"deleteWarning_other": "You are about to delete {{count}} images. This action cannot be undone.",
|
||||
"deleteWarningWithThumbnail_one": "You are about to delete {{count}} image including the thumbnail. This action cannot be undone.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
|
||||
import { Image, Product } from "@medusajs/medusa"
|
||||
import { Button, CommandBar, Tooltip, clx } from "@medusajs/ui"
|
||||
import { Button, CommandBar, Tooltip, clx, toast } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { Fragment, useCallback, useState } from "react"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { sdk } from "../../../../../lib/client"
|
||||
|
||||
type ProductMediaViewProps = {
|
||||
product: Product
|
||||
@@ -73,7 +74,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
keyName: "field_id",
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading } = useUpdateProduct(product.id)
|
||||
const { mutateAsync, isPending } = useUpdateProduct(product.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async ({ media }) => {
|
||||
const urls = media.map((m) => m.url)
|
||||
@@ -85,20 +86,17 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
if (filesToUpload.length) {
|
||||
const files = filesToUpload.map((m) => m.file) as File[]
|
||||
|
||||
// TODO: Implement upload to Medusa
|
||||
// const uploads = await client.admin.uploads
|
||||
// .create(files)
|
||||
// .then((res) => {
|
||||
// return res.uploads
|
||||
// })
|
||||
// .catch((_err) => {
|
||||
// // Show error message
|
||||
// return null
|
||||
// })
|
||||
|
||||
const uploads = files.map((file) => ({
|
||||
url: URL.createObjectURL(file),
|
||||
}))
|
||||
const uploads = await sdk.admin.uploads
|
||||
.create({ files })
|
||||
.then((res) => {
|
||||
return res.files
|
||||
})
|
||||
.catch(() => {
|
||||
form.setError("media", {
|
||||
type: "invalid_file",
|
||||
message: t("products.media.failedToUpload"),
|
||||
})
|
||||
})
|
||||
|
||||
if (!uploads) {
|
||||
return
|
||||
@@ -116,7 +114,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
images: urls,
|
||||
images: urls.map((url) => ({ url })),
|
||||
// Set thumbnail to empty string if no thumbnail is selected, as the API does not accept null
|
||||
thumbnail: thumbnail || "",
|
||||
},
|
||||
@@ -216,7 +214,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
||||
{t("products.media.galleryLabel")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="small" type="submit" isLoading={isLoading}>
|
||||
<Button size="small" type="submit" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -314,4 +314,65 @@ export class Admin {
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
public uploads = {
|
||||
// Note: The creation/upload flow be made more advanced, with support for streaming and progress, but for now we keep it simple
|
||||
create: async (
|
||||
body: HttpTypes.AdminUploadFile,
|
||||
query?: SelectParams,
|
||||
headers?: ClientHeaders
|
||||
) => {
|
||||
const form = new FormData()
|
||||
if (body instanceof FileList) {
|
||||
Array.from(body).forEach((file) => {
|
||||
form.append("files", file)
|
||||
})
|
||||
} else {
|
||||
body.files.forEach((file) => {
|
||||
form.append(
|
||||
"files",
|
||||
"content" in file
|
||||
? new Blob([file.content], {
|
||||
type: "text/plain",
|
||||
})
|
||||
: file,
|
||||
file.name
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return this.client.fetch<{ files: HttpTypes.AdminFile[] }>(
|
||||
`/admin/uploads`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
// Let the browser determine the content type.
|
||||
"content-type": null,
|
||||
},
|
||||
body: form,
|
||||
query,
|
||||
}
|
||||
)
|
||||
},
|
||||
retrieve: async (
|
||||
id: string,
|
||||
query?: SelectParams,
|
||||
headers?: ClientHeaders
|
||||
) => {
|
||||
return this.client.fetch<{ file: HttpTypes.AdminFile }>(
|
||||
`/admin/uploads/${id}`,
|
||||
{
|
||||
query,
|
||||
headers,
|
||||
}
|
||||
)
|
||||
},
|
||||
delete: async (id: string, headers?: ClientHeaders) => {
|
||||
return this.client.fetch<DeleteResponse<"file">>(`/admin/uploads/${id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,11 @@ export class Client {
|
||||
}
|
||||
// We use `headers.set` in order to ensure headers are overwritten in a case-insensitive manner.
|
||||
Object.entries(customHeaders).forEach(([key, value]) => {
|
||||
headers.set(key, value)
|
||||
if (value === null) {
|
||||
headers.delete(key)
|
||||
} else {
|
||||
headers.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
let normalizedInput: RequestInfo | URL = input
|
||||
|
||||
@@ -23,7 +23,7 @@ export type FetchParams = Parameters<typeof fetch>
|
||||
|
||||
export type ClientHeaders =
|
||||
// The `tags` header is specifically added for nextJS, as they follow a non-standard header format
|
||||
Record<string, string | { tags: string[] }>
|
||||
Record<string, string | null | { tags: string[] }>
|
||||
|
||||
export type FetchInput = FetchParams[0]
|
||||
|
||||
|
||||
5
packages/core/types/src/http/file/admin.ts
Normal file
5
packages/core/types/src/http/file/admin.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BaseFile, BaseUploadFile } from "./common"
|
||||
|
||||
export interface AdminFile extends BaseFile {}
|
||||
|
||||
export type AdminUploadFile = BaseUploadFile
|
||||
10
packages/core/types/src/http/file/common.ts
Normal file
10
packages/core/types/src/http/file/common.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface BaseFile {
|
||||
id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type BaseUploadFile =
|
||||
| {
|
||||
files: ({ name: string; content: string } | File)[]
|
||||
}
|
||||
| FileList
|
||||
2
packages/core/types/src/http/file/index.ts
Normal file
2
packages/core/types/src/http/file/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./admin"
|
||||
export * from "./store"
|
||||
3
packages/core/types/src/http/file/store.ts
Normal file
3
packages/core/types/src/http/file/store.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { BaseFile } from "./common"
|
||||
|
||||
export interface StoreFile extends BaseFile {}
|
||||
@@ -19,3 +19,4 @@ export * from "./sales-channel"
|
||||
export * from "./stock-locations"
|
||||
export * from "./tax"
|
||||
export * from "./user"
|
||||
export * from "./file"
|
||||
|
||||
@@ -99,6 +99,7 @@ export class S3FileService extends AbstractFileProviderService {
|
||||
// protected private_secret_access_key_: string
|
||||
|
||||
// ACL: options.acl ?? (options.isProtected ? "private" : "public-read"),
|
||||
ACL: "public-read",
|
||||
Bucket: this.config_.bucket,
|
||||
Body: content,
|
||||
Key: fileKey,
|
||||
|
||||
Reference in New Issue
Block a user