feat(dashboard, ui): Product image modals (#6779)
**What** - Adds Media modals gallery and edit mode - Fixes an issue with Hint from medusajs/ui.
This commit is contained in:
committed by
GitHub
parent
1b2e0b4031
commit
247ca3c3fa
5
.changeset/curvy-coins-stare.md
Normal file
5
.changeset/curvy-coins-stare.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/ui": patch
|
||||
---
|
||||
|
||||
fix(ui): Ensure that Hint's with variant 'error' are not squished when constrained.
|
||||
@@ -149,13 +149,22 @@
|
||||
"editOrganization": "Edit Organization",
|
||||
"options": "Options",
|
||||
"editOptions": "Edit Options",
|
||||
"media": "Media",
|
||||
"editMedia": "Edit Media",
|
||||
"deleteMedia_one": "You are about to delete {{count}} media item. This action cannot be undone.",
|
||||
"deleteMedia_other": "You are about to delete {{count}} media items. This action cannot be undone.",
|
||||
"deleteMediaAndThumbnail_one": "You are about to delete {{count}} media item including the thumbnail. This action cannot be undone.",
|
||||
"deleteMediaAndThumbnail_other": "You are about to delete {{count}} media items including the thumbnail. This action cannot be undone.",
|
||||
"gallery": "Gallery",
|
||||
"media": {
|
||||
"label": "Media",
|
||||
"editHint": "Add media to the product to showcase it in your storefront.",
|
||||
"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}}.",
|
||||
"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.",
|
||||
"deleteWarningWithThumbnail_other": "You are about to delete {{count}} images including the thumbnail. This action cannot be undone.",
|
||||
"thumbnailTooltip": "Thumbnail",
|
||||
"galleryLabel": "Gallery",
|
||||
"downloadImageLabel": "Download current image",
|
||||
"deleteImageLabel": "Delete current image",
|
||||
"noMediaLabel": "The product has no associated media."
|
||||
},
|
||||
"titleHint": "Give your product a short and clear title.<0/>50-60 characters is the recommended length for search engines.",
|
||||
"descriptionHint": "Give your product a short and clear description.<0/>120-160 characters is the recommended length for search engines.",
|
||||
"discountableHint": "When unchecked discounts will not be applied to this product.",
|
||||
|
||||
@@ -7,10 +7,12 @@ import { Form } from "../../common/form"
|
||||
|
||||
type RouteFormProps<TFieldValues extends FieldValues> = PropsWithChildren<{
|
||||
form: UseFormReturn<TFieldValues>
|
||||
blockSearch?: boolean
|
||||
}>
|
||||
|
||||
export const RouteForm = <TFieldValues extends FieldValues = any>({
|
||||
form,
|
||||
blockSearch = false,
|
||||
children,
|
||||
}: RouteFormProps<TFieldValues>) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -26,7 +28,14 @@ export const RouteForm = <TFieldValues extends FieldValues = any>({
|
||||
return false
|
||||
}
|
||||
|
||||
return isDirty && currentLocation.pathname !== nextLocation.pathname
|
||||
const isPathChanged = currentLocation.pathname !== nextLocation.pathname
|
||||
const isSearchChanged = currentLocation.search !== nextLocation.search
|
||||
|
||||
if (blockSearch) {
|
||||
return isDirty && (isPathChanged || isSearchChanged)
|
||||
}
|
||||
|
||||
return isDirty && isPathChanged
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
|
||||
@@ -225,8 +225,8 @@ export const v1Routes: RouteObject[] = [
|
||||
lazy: () => import("../../routes/products/product-options"),
|
||||
},
|
||||
{
|
||||
path: "gallery",
|
||||
lazy: () => import("../../routes/products/product-gallery"),
|
||||
path: "media",
|
||||
lazy: () => import("../../routes/products/product-media"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createContext } from "react";
|
||||
import { createContext } from "react"
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
export type Theme = "light" | "dark"
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PencilSquare, Photo, ThumbnailBadge } from "@medusajs/icons"
|
||||
import { PencilSquare, ThumbnailBadge } from "@medusajs/icons"
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import {
|
||||
Checkbox,
|
||||
@@ -48,10 +48,10 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: includingThumbnail
|
||||
? t("products.deleteMediaAndThumbnail", {
|
||||
? t("products.media.deleteWarningWithThumbnail", {
|
||||
count: ids.length,
|
||||
})
|
||||
: t("products.deleteMedia", {
|
||||
: t("products.media.deleteWarning", {
|
||||
count: ids.length,
|
||||
}),
|
||||
confirmText: t("actions.delete"),
|
||||
@@ -66,30 +66,32 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
.filter((i) => !ids.includes(i.id))
|
||||
.map((i) => i.url)
|
||||
|
||||
await mutateAsync({
|
||||
images: mediaToKeep,
|
||||
thumbnail: includingThumbnail ? "" : undefined,
|
||||
})
|
||||
await mutateAsync(
|
||||
{
|
||||
images: mediaToKeep,
|
||||
thumbnail: includingThumbnail ? "" : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelection({})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("products.media")}</Heading>
|
||||
<Heading level="h2">{t("products.media.label")}</Heading>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
to: "media",
|
||||
to: "media?view=edit",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
{
|
||||
label: t("products.gallery"),
|
||||
to: "gallery",
|
||||
icon: <Photo />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
@@ -97,7 +99,9 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
</div>
|
||||
{media && (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-4 px-6 py-4">
|
||||
{media.map((i) => {
|
||||
{media.map((i, index) => {
|
||||
const isSelected = selection[i.id]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="shadow-elevation-card-rest hover:shadow-elevation-card-hover transition-fg group relative aspect-square size-full cursor-pointer overflow-hidden rounded-[8px]"
|
||||
@@ -107,7 +111,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
className={clx(
|
||||
"transition-fg invisible absolute right-2 top-2 opacity-0 group-hover:visible group-hover:opacity-100",
|
||||
{
|
||||
"visible opacity-100": Object.keys(selection).length > 0,
|
||||
"visible opacity-100": isSelected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
@@ -123,7 +127,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<Link to={`gallery?img=${i.id}`}>
|
||||
<Link to={`media`} state={{ curr: index }}>
|
||||
<img
|
||||
src={i.url}
|
||||
alt={`${product.title} image`}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ProductGallery as Component } from "./product-gallery"
|
||||
@@ -1,206 +0,0 @@
|
||||
import {
|
||||
ArrowDownTray,
|
||||
ChevronLeftMini,
|
||||
ChevronRightMini,
|
||||
ThumbnailBadge,
|
||||
Trash,
|
||||
XMarkMini,
|
||||
} from "@medusajs/icons"
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { Button, IconButton, Kbd, Tooltip } from "@medusajs/ui"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import { Variants, motion } from "framer-motion"
|
||||
import { useAdminProduct } from "medusa-react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom"
|
||||
|
||||
export const ProductGallery = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const { id } = useParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
const { product, isLoading, isError, error } = useAdminProduct(id!)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const media = useMemo(() => {
|
||||
return product ? getMedia(product) : []
|
||||
}, [product])
|
||||
|
||||
const currentId = searchParams.get("img") ?? media[0]?.id
|
||||
const currentIndex = media.findIndex((m) => m.id === currentId)
|
||||
|
||||
const paginate = useCallback(
|
||||
(newDirection: number) => {
|
||||
const adjustment = newDirection > 0 ? 1 : -1
|
||||
const newIndex = (currentIndex + adjustment + media.length) % media.length
|
||||
setSearchParams({ img: media[newIndex].id }, { replace: true })
|
||||
},
|
||||
[currentIndex, media, setSearchParams]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault()
|
||||
paginate(1)
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault()
|
||||
paginate(-1)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [paginate])
|
||||
|
||||
const indicatorVariants: Variants = {
|
||||
active: {
|
||||
width: "16px",
|
||||
backgroundColor: "var(--fg-subtle)",
|
||||
},
|
||||
inactive: {
|
||||
width: "6px",
|
||||
backgroundColor: "var(--fg-muted)",
|
||||
},
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root modal open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Content className="bg-ui-bg-subtle dark fixed inset-0 grid-rows-[32px_1fr_6px] pb-16 pt-4 outline-none">
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Dialog.Close asChild>
|
||||
<IconButton size="small" variant="transparent">
|
||||
<XMarkMini className="text-ui-fg-subtle" />
|
||||
</IconButton>
|
||||
</Dialog.Close>
|
||||
<Kbd>esc</Kbd>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Tooltip
|
||||
content={t("actions.delete")}
|
||||
side="bottom"
|
||||
className="dark"
|
||||
>
|
||||
<IconButton
|
||||
disabled={isLoading}
|
||||
aria-label="Delete image"
|
||||
size="small"
|
||||
>
|
||||
<Trash aria-hidden className="text-ui-fg-subtle" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t("actions.download")}
|
||||
side="bottom"
|
||||
className="dark"
|
||||
>
|
||||
<IconButton
|
||||
disabled={isLoading}
|
||||
aria-label="Download image"
|
||||
size="small"
|
||||
asChild
|
||||
>
|
||||
<Link to={media[currentIndex].url} download>
|
||||
<ArrowDownTray aria-hidden className="text-ui-fg-subtle" />
|
||||
</Link>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{t("products.editMedia")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<main className="relative flex h-full w-screen items-center justify-center py-16">
|
||||
<div className="absolute h-full max-w-[100vw] rounded-2xl py-16">
|
||||
<div className="relative h-full w-fit">
|
||||
{media[currentIndex].isThumbnail && (
|
||||
<div className="absolute left-3 top-3">
|
||||
<Tooltip content={t("fields.thumbnail")} className="dark">
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={media[currentIndex].url}
|
||||
alt=""
|
||||
className="object-fit h-full rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
onClick={() => paginate(-1)}
|
||||
className="absolute left-4 top-1/2 z-[2] rounded-full"
|
||||
>
|
||||
<ChevronLeftMini className="text-ui-fg-subtle" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => paginate(1)}
|
||||
className="absolute right-4 top-1/2 z-[2] rounded-full"
|
||||
>
|
||||
<ChevronRightMini className="text-ui-fg-subtle" />
|
||||
</IconButton>
|
||||
</main>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{media.map((img, index) => (
|
||||
<motion.div
|
||||
key={img.id}
|
||||
className="h-1.5 rounded-full"
|
||||
variants={indicatorVariants}
|
||||
animate={index === currentIndex ? "active" : "inactive"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
type Media = {
|
||||
id: string
|
||||
url: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
const getMedia = (product: Product) => {
|
||||
const { images = [], thumbnail } = product
|
||||
|
||||
const media: Media[] = images.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
isThumbnail: image.url === thumbnail,
|
||||
}))
|
||||
|
||||
if (thumbnail && !media.some((mediaItem) => mediaItem.url === thumbnail)) {
|
||||
media.unshift({
|
||||
id: "img_thumbnail",
|
||||
url: thumbnail,
|
||||
isThumbnail: true,
|
||||
})
|
||||
}
|
||||
|
||||
return media
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
ArrowDownTray,
|
||||
CheckMini,
|
||||
Spinner,
|
||||
ThumbnailBadge,
|
||||
} from "@medusajs/icons"
|
||||
import { Image, Product } from "@medusajs/medusa"
|
||||
import { Button, CommandBar, Text, Tooltip, clx } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { useAdminUpdateProduct, useMedusa } from "medusa-react"
|
||||
import {
|
||||
ChangeEvent,
|
||||
DragEvent,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useRef,
|
||||
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 { Form } from "../../../../../components/common/form"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
|
||||
type ProductMediaViewProps = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
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 [isDragOver, setIsDragOver] = useState<boolean>(false)
|
||||
|
||||
const dropZoneRef = useRef<HTMLButtonElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<z.infer<typeof EditProductMediaSchema>>({
|
||||
defaultValues: {
|
||||
media: getDefaultValues(product.images, product.thumbnail),
|
||||
},
|
||||
resolver: zodResolver(EditProductMediaSchema),
|
||||
})
|
||||
|
||||
const { fields, append, remove, update } = useFieldArray({
|
||||
name: "media",
|
||||
control: form.control,
|
||||
keyName: "field_id",
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id)
|
||||
const { client } = useMedusa()
|
||||
|
||||
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)
|
||||
|
||||
if (filesToUpload.length) {
|
||||
const files = filesToUpload.map((m) => m.file) as File[]
|
||||
|
||||
const uploads = await client.admin.uploads
|
||||
.create(files)
|
||||
.then((res) => {
|
||||
return res.uploads
|
||||
})
|
||||
.catch((_err) => {
|
||||
// Show error message
|
||||
return null
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
const thumbnailIndex = media.findIndex((m) => m.isThumbnail)
|
||||
const thumbnail = thumbnailIndex > -1 ? urls[thumbnailIndex] : null
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
images: urls,
|
||||
// Set thumbnail to empty string if no thumbnail is selected, as the API does not accept null
|
||||
thumbnail: thumbnail || "",
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleSuccess()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const handleOpenFileSelector = () => {
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const hasInvalidFiles = (fileList: File[]) => {
|
||||
const invalidFile = fileList.find(
|
||||
(f) => !SUPPORTED_FORMATS.includes(f.type)
|
||||
)
|
||||
|
||||
if (invalidFile) {
|
||||
form.setError("media", {
|
||||
type: "invalid_file",
|
||||
message: t("products.media.invalidFileType", {
|
||||
name: invalidFile.name,
|
||||
types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
|
||||
}),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
if (files) {
|
||||
const fileList = Array.from(files)
|
||||
|
||||
if (hasInvalidFiles(fileList)) {
|
||||
return
|
||||
}
|
||||
|
||||
form.clearErrors("media")
|
||||
|
||||
fileList.forEach((file) => {
|
||||
const preview = URL.createObjectURL(file)
|
||||
|
||||
append({
|
||||
id: crypto.randomUUID(),
|
||||
url: preview,
|
||||
isThumbnail: false,
|
||||
file,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const files = event.dataTransfer.files
|
||||
|
||||
if (files) {
|
||||
const fileList = Array.from(files)
|
||||
|
||||
console.log(fileList.map((f) => f.type))
|
||||
|
||||
if (hasInvalidFiles(fileList)) {
|
||||
console.log("invalid files")
|
||||
setIsDragOver(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (
|
||||
!dropZoneRef.current ||
|
||||
dropZoneRef.current.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(false)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setIsDragOver(false)
|
||||
form.clearErrors("media")
|
||||
|
||||
const files = event.dataTransfer.files
|
||||
|
||||
if (files) {
|
||||
const fileList = Array.from(files)
|
||||
|
||||
if (hasInvalidFiles(fileList)) {
|
||||
return
|
||||
}
|
||||
|
||||
fileList.forEach((file) => {
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
|
||||
append({
|
||||
id: crypto.randomUUID(),
|
||||
url: previewUrl,
|
||||
isThumbnail: false,
|
||||
file,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
remove(indices)
|
||||
setSelection({})
|
||||
}
|
||||
|
||||
const handlePromoteToThumbnail = () => {
|
||||
const ids = Object.keys(selection)
|
||||
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentThumbnailIndex = fields.findIndex((m) => m.isThumbnail)
|
||||
|
||||
if (currentThumbnailIndex > -1) {
|
||||
update(currentThumbnailIndex, {
|
||||
...fields[currentThumbnailIndex],
|
||||
isThumbnail: false,
|
||||
})
|
||||
}
|
||||
|
||||
const index = fields.findIndex((m) => m.id === ids[0])
|
||||
|
||||
update(index, {
|
||||
...fields[index],
|
||||
isThumbnail: true,
|
||||
})
|
||||
|
||||
setSelection({})
|
||||
}
|
||||
|
||||
const selectionCount = Object.keys(selection).length
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form blockSearch form={form}>
|
||||
<form
|
||||
className="flex size-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button variant="secondary" size="small" asChild>
|
||||
<Link to={{ pathname: ".", search: undefined }}>
|
||||
{t("products.media.galleryLabel")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="small" type="submit" isLoading={isLoading}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<div>
|
||||
<button
|
||||
ref={dropZoneRef}
|
||||
type="button"
|
||||
onClick={handleOpenFileSelector}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={clx(
|
||||
"bg-ui-bg-component border-ui-border-strong transition-fg group flex w-full flex-col items-center gap-y-2 rounded-lg border border-dashed p-8",
|
||||
"hover:border-ui-border-interactive focus:border-ui-border-interactive",
|
||||
"focus:shadow-borders-focus outline-none focus:border-solid",
|
||||
{
|
||||
"!border-ui-border-error":
|
||||
!!form.formState.errors.media,
|
||||
"!border-ui-border-interactive": isDragOver,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="text-ui-fg-subtle group-disabled:text-ui-fg-disabled flex items-center gap-x-2">
|
||||
<ArrowDownTray />
|
||||
<Text>
|
||||
{t("products.media.uploadImagesLabel")}
|
||||
</Text>
|
||||
</div>
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted group-disabled:text-ui-fg-disabled"
|
||||
>
|
||||
{t("products.media.uploadImagesHint")}
|
||||
</Text>
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
ref={inputRef}
|
||||
onChange={handleFileChange}
|
||||
type="file"
|
||||
accept={SUPPORTED_FORMATS.join(",")}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Body>
|
||||
<CommandBar open={!!selectionCount}>
|
||||
<CommandBar.Bar>
|
||||
<CommandBar.Value>
|
||||
{t("general.countSelected", {
|
||||
count: selectionCount,
|
||||
})}
|
||||
</CommandBar.Value>
|
||||
<CommandBar.Seperator />
|
||||
{selectionCount === 1 && (
|
||||
<Fragment>
|
||||
<CommandBar.Command
|
||||
action={handlePromoteToThumbnail}
|
||||
label={"Make thumbnail"}
|
||||
shortcut="t"
|
||||
/>
|
||||
<CommandBar.Seperator />
|
||||
</Fragment>
|
||||
)}
|
||||
<CommandBar.Command
|
||||
action={handleDelete}
|
||||
label={t("actions.delete")}
|
||||
shortcut="d"
|
||||
/>
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
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 media: Media[] =
|
||||
images?.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
isThumbnail: image.url === thumbnail,
|
||||
file: null,
|
||||
})) || []
|
||||
|
||||
if (thumbnail && !media.some((mediaItem) => mediaItem.url === thumbnail)) {
|
||||
media.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
url: thumbnail,
|
||||
isThumbnail: true,
|
||||
file: null,
|
||||
})
|
||||
}
|
||||
|
||||
return media
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-product-media-form"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./product-media-gallery"
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
ArrowDownTray,
|
||||
ExclamationCircle,
|
||||
ThumbnailBadge,
|
||||
Trash,
|
||||
TriangleLeftMini,
|
||||
TriangleRightMini,
|
||||
} from "@medusajs/icons"
|
||||
import { Image, Product } from "@medusajs/medusa"
|
||||
import { Button, IconButton, Text, Tooltip, clx, usePrompt } from "@medusajs/ui"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
|
||||
import { useAdminUpdateProduct } from "medusa-react"
|
||||
import { RouteFocusModal } from "../../../../../components/route-modal"
|
||||
|
||||
type ProductMediaGalleryProps = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => {
|
||||
const { state } = useLocation()
|
||||
const [curr, setCurr] = useState<number>(state?.curr || 0)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id)
|
||||
|
||||
const media = getMedia(product.images, product.thumbnail)
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurr((prev) => (prev + 1) % media.length)
|
||||
}, [media, isLoading])
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurr((prev) => (prev - 1 + media.length) % media.length)
|
||||
}, [media, isLoading])
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurr(index)
|
||||
},
|
||||
[isLoading]
|
||||
)
|
||||
|
||||
const handleDownloadCurrent = () => {
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
const a = document.createElement("a") as HTMLAnchorElement & {
|
||||
download: string
|
||||
}
|
||||
|
||||
a.href = media[curr].url
|
||||
a.download = "image"
|
||||
a.target = "_blank"
|
||||
|
||||
a.click()
|
||||
}
|
||||
|
||||
const handleDeleteCurrent = async () => {
|
||||
const current = media[curr]
|
||||
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: current.isThumbnail
|
||||
? t("products.media.deleteWarningWithThumbnail", { count: 1 })
|
||||
: t("products.media.deleteWarning", { count: 1 }),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
const mediaToKeep = product.images
|
||||
.filter((i) => i.id !== current.id)
|
||||
.map((i) => i.url)
|
||||
|
||||
if (curr === media.length - 1) {
|
||||
setCurr((prev) => prev - 1)
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
images: mediaToKeep,
|
||||
thumbnail: current.isThumbnail ? "" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight") {
|
||||
next()
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
prev()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [next, prev])
|
||||
|
||||
const noMedia = !media.length
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<IconButton
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleDeleteCurrent}
|
||||
disabled={noMedia}
|
||||
>
|
||||
<Trash />
|
||||
<span className="sr-only">
|
||||
{t("products.media.deleteImageLabel")}
|
||||
</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleDownloadCurrent}
|
||||
disabled={noMedia}
|
||||
>
|
||||
<ArrowDownTray />
|
||||
<span className="sr-only">
|
||||
{t("products.media.downloadImageLabel")}
|
||||
</span>
|
||||
</IconButton>
|
||||
<Button variant="secondary" size="small" asChild>
|
||||
<Link to={{ pathname: ".", search: "view=edit" }}>
|
||||
{t("actions.edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
|
||||
<Canvas curr={curr} media={media} />
|
||||
<Preview
|
||||
curr={curr}
|
||||
media={media}
|
||||
prev={prev}
|
||||
next={next}
|
||||
goTo={goTo}
|
||||
/>
|
||||
</RouteFocusModal.Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (media.length === 0) {
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle text-ui-fg-subtle flex size-full flex-col items-center justify-center gap-y-2">
|
||||
<ExclamationCircle />
|
||||
<Text size="small">{t("products.media.noMediaLabel")}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle relative size-full overflow-hidden">
|
||||
<div className="flex size-full items-center justify-center p-6">
|
||||
<div className="relative h-full w-fit">
|
||||
{media[curr].isThumbnail && (
|
||||
<div className="absolute left-2 top-2">
|
||||
<Tooltip content={t("products.media.thumbnailTooltip")}>
|
||||
<ThumbnailBadge />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={media[curr].url}
|
||||
alt=""
|
||||
className="object-fit shadow-elevation-card-rest size-full rounded-xl object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex shrink-0 items-center justify-center gap-x-2 border-t p-3">
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted"
|
||||
type="button"
|
||||
onClick={prev}
|
||||
>
|
||||
<TriangleLeftMini />
|
||||
</IconButton>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{visibleItems.map((item) => {
|
||||
const isCurrentImage = item.id === media[curr].id
|
||||
const originalIndex = media.findIndex((i) => i.id === item.id)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goTo(originalIndex)}
|
||||
className={clx(
|
||||
"transition-fg size-7 overflow-hidden rounded-[4px] outline-none",
|
||||
{
|
||||
"shadow-borders-focus": isCurrentImage,
|
||||
}
|
||||
)}
|
||||
key={item.id}
|
||||
>
|
||||
<img src={item.url} alt="" className="size-full object-cover" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted"
|
||||
type="button"
|
||||
onClick={next}
|
||||
>
|
||||
<TriangleRightMini />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Media = {
|
||||
id: string
|
||||
url: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
const getMedia = (images: Image[] | null, thumbnail: string | null) => {
|
||||
const media: Media[] =
|
||||
images?.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
isThumbnail: image.url === thumbnail,
|
||||
})) || []
|
||||
|
||||
if (thumbnail && !media.some((mediaItem) => mediaItem.isThumbnail)) {
|
||||
media.unshift({
|
||||
id: "thumbnail_only",
|
||||
url: thumbnail,
|
||||
isThumbnail: true,
|
||||
})
|
||||
}
|
||||
|
||||
return media
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./product-media-view"
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
type ProductMediaViewContextValue = {
|
||||
goToGallery: () => void
|
||||
goToEdit: () => void
|
||||
}
|
||||
|
||||
export const ProductMediaViewContext =
|
||||
createContext<ProductMediaViewContextValue | null>(null)
|
||||
@@ -0,0 +1,55 @@
|
||||
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"
|
||||
|
||||
type ProductMediaViewProps = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
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 ProductMediaView = ({ product }: ProductMediaViewProps) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const view = getView(searchParams)
|
||||
|
||||
const handleGoToView = (view: View) => {
|
||||
return () => {
|
||||
setSearchParams({ view })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProductMediaViewContext.Provider
|
||||
value={{
|
||||
goToGallery: handleGoToView(View.GALLERY),
|
||||
goToEdit: handleGoToView(View.EDIT),
|
||||
}}
|
||||
>
|
||||
{renderView(view, product)}
|
||||
</ProductMediaViewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderView = (view: View, product: Product) => {
|
||||
switch (view) {
|
||||
case View.GALLERY:
|
||||
return <ProductMediaGallery product={product} />
|
||||
case View.EDIT:
|
||||
return <EditProductMediaForm product={product} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react"
|
||||
import { ProductMediaViewContext } from "./product-media-view-context"
|
||||
|
||||
export const useProductMediaView = () => {
|
||||
const context = useContext(ProductMediaViewContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useProductMediaView must be used within a ProductMediaViewProvider"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductMedia as Component } from "./product-media"
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useAdminProduct } from "medusa-react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { ProductMediaView } from "./components/product-media-view"
|
||||
|
||||
export const ProductMedia = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { product, isLoading, isError, error } = useAdminProduct(id!)
|
||||
|
||||
const ready = !isLoading && product
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{ready && <ProductMediaView product={product} />}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import * as React from "react"
|
||||
import { clx } from "../../utils/clx"
|
||||
|
||||
const hintVariants = cva({
|
||||
base: "txt-small inline-flex items-start gap-x-2",
|
||||
base: "txt-small",
|
||||
variants: {
|
||||
variant: {
|
||||
info: "text-ui-fg-subtle",
|
||||
error: "text-ui-fg-error",
|
||||
error: "text-ui-fg-error grid grid-cols-[20px_1fr] gap-2 items-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
Reference in New Issue
Block a user