feat: Add support for uploading media in admin (#7564)
This commit is contained in:
@@ -210,6 +210,7 @@
|
|||||||
"uploadImagesLabel": "Upload images",
|
"uploadImagesLabel": "Upload images",
|
||||||
"uploadImagesHint": "Drag and drop images here or click to upload.",
|
"uploadImagesHint": "Drag and drop images here or click to upload.",
|
||||||
"invalidFileType": "'{{name}}' is not a supported file type. Supported file types are: {{types}}.",
|
"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_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.",
|
"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.",
|
"deleteWarningWithThumbnail_one": "You are about to delete {{count}} image including the thumbnail. This action cannot be undone.",
|
||||||
|
|||||||
+16
-18
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
|
import { CheckMini, Spinner, ThumbnailBadge } from "@medusajs/icons"
|
||||||
import { Image, Product } from "@medusajs/medusa"
|
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 { AnimatePresence, motion } from "framer-motion"
|
||||||
import { Fragment, useCallback, useState } from "react"
|
import { Fragment, useCallback, useState } from "react"
|
||||||
import { useFieldArray, useForm } from "react-hook-form"
|
import { useFieldArray, useForm } from "react-hook-form"
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
useRouteModal,
|
useRouteModal,
|
||||||
} from "../../../../../components/route-modal"
|
} from "../../../../../components/route-modal"
|
||||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||||
|
import { sdk } from "../../../../../lib/client"
|
||||||
|
|
||||||
type ProductMediaViewProps = {
|
type ProductMediaViewProps = {
|
||||||
product: Product
|
product: Product
|
||||||
@@ -73,7 +74,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
|||||||
keyName: "field_id",
|
keyName: "field_id",
|
||||||
})
|
})
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = useUpdateProduct(product.id)
|
const { mutateAsync, isPending } = useUpdateProduct(product.id)
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit(async ({ media }) => {
|
const handleSubmit = form.handleSubmit(async ({ media }) => {
|
||||||
const urls = media.map((m) => m.url)
|
const urls = media.map((m) => m.url)
|
||||||
@@ -85,20 +86,17 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
|||||||
if (filesToUpload.length) {
|
if (filesToUpload.length) {
|
||||||
const files = filesToUpload.map((m) => m.file) as File[]
|
const files = filesToUpload.map((m) => m.file) as File[]
|
||||||
|
|
||||||
// TODO: Implement upload to Medusa
|
const uploads = await sdk.admin.uploads
|
||||||
// const uploads = await client.admin.uploads
|
.create({ files })
|
||||||
// .create(files)
|
.then((res) => {
|
||||||
// .then((res) => {
|
return res.files
|
||||||
// return res.uploads
|
})
|
||||||
// })
|
.catch(() => {
|
||||||
// .catch((_err) => {
|
form.setError("media", {
|
||||||
// // Show error message
|
type: "invalid_file",
|
||||||
// return null
|
message: t("products.media.failedToUpload"),
|
||||||
// })
|
})
|
||||||
|
})
|
||||||
const uploads = files.map((file) => ({
|
|
||||||
url: URL.createObjectURL(file),
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (!uploads) {
|
if (!uploads) {
|
||||||
return
|
return
|
||||||
@@ -116,7 +114,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
|||||||
|
|
||||||
await mutateAsync(
|
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
|
// Set thumbnail to empty string if no thumbnail is selected, as the API does not accept null
|
||||||
thumbnail: thumbnail || "",
|
thumbnail: thumbnail || "",
|
||||||
},
|
},
|
||||||
@@ -216,7 +214,7 @@ export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => {
|
|||||||
{t("products.media.galleryLabel")}
|
{t("products.media.galleryLabel")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" type="submit" isLoading={isLoading}>
|
<Button size="small" type="submit" isLoading={isPending}>
|
||||||
{t("actions.save")}
|
{t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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.
|
// We use `headers.set` in order to ensure headers are overwritten in a case-insensitive manner.
|
||||||
Object.entries(customHeaders).forEach(([key, value]) => {
|
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
|
let normalizedInput: RequestInfo | URL = input
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export type FetchParams = Parameters<typeof fetch>
|
|||||||
|
|
||||||
export type ClientHeaders =
|
export type ClientHeaders =
|
||||||
// The `tags` header is specifically added for nextJS, as they follow a non-standard header format
|
// 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]
|
export type FetchInput = FetchParams[0]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseFile, BaseUploadFile } from "./common"
|
||||||
|
|
||||||
|
export interface AdminFile extends BaseFile {}
|
||||||
|
|
||||||
|
export type AdminUploadFile = BaseUploadFile
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface BaseFile {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseUploadFile =
|
||||||
|
| {
|
||||||
|
files: ({ name: string; content: string } | File)[]
|
||||||
|
}
|
||||||
|
| FileList
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./admin"
|
||||||
|
export * from "./store"
|
||||||
@@ -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 "./stock-locations"
|
||||||
export * from "./tax"
|
export * from "./tax"
|
||||||
export * from "./user"
|
export * from "./user"
|
||||||
|
export * from "./file"
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export class S3FileService extends AbstractFileProviderService {
|
|||||||
// protected private_secret_access_key_: string
|
// protected private_secret_access_key_: string
|
||||||
|
|
||||||
// ACL: options.acl ?? (options.isProtected ? "private" : "public-read"),
|
// ACL: options.acl ?? (options.isProtected ? "private" : "public-read"),
|
||||||
|
ACL: "public-read",
|
||||||
Bucket: this.config_.bucket,
|
Bucket: this.config_.bucket,
|
||||||
Body: content,
|
Body: content,
|
||||||
Key: fileKey,
|
Key: fileKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user