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:
1 parent
1b2e0b4031
commit
247ca3c3fa
19 files changed
+1016
-242
No files matched your search
@@ -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",
|
"editOrganization": "Edit Organization",
|
||||||
"options": "Options",
|
"options": "Options",
|
||||||
"editOptions": "Edit Options",
|
"editOptions": "Edit Options",
|
||||||
"media": "Media",
|
"media": {
|
||||||
"editMedia": "Edit Media",
|
"label": "Media",
|
||||||
"deleteMedia_one": "You are about to delete {{count}} media item. This action cannot be undone.",
|
"editHint": "Add media to the product to showcase it in your storefront.",
|
||||||
"deleteMedia_other": "You are about to delete {{count}} media items. This action cannot be undone.",
|
"uploadImagesLabel": "Upload images",
|
||||||
"deleteMediaAndThumbnail_one": "You are about to delete {{count}} media item including the thumbnail. This action cannot be undone.",
|
"uploadImagesHint": "Drag and drop images here or click to upload.",
|
||||||
"deleteMediaAndThumbnail_other": "You are about to delete {{count}} media items including the thumbnail. This action cannot be undone.",
|
"invalidFileType": "'{{name}}' is not a supported file type. Supported file types are: {{types}}.",
|
||||||
"gallery": "Gallery",
|
"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.",
|
"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.",
|
"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.",
|
"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<{
|
type RouteFormProps<TFieldValues extends FieldValues> = PropsWithChildren<{
|
||||||
form: UseFormReturn<TFieldValues>
|
form: UseFormReturn<TFieldValues>
|
||||||
|
blockSearch?: boolean
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export const RouteForm = <TFieldValues extends FieldValues = any>({
|
export const RouteForm = <TFieldValues extends FieldValues = any>({
|
||||||
form,
|
form,
|
||||||
|
blockSearch = false,
|
||||||
children,
|
children,
|
||||||
}: RouteFormProps<TFieldValues>) => {
|
}: RouteFormProps<TFieldValues>) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -26,7 +28,14 @@ export const RouteForm = <TFieldValues extends FieldValues = any>({
|
|||||||
return false
|
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 = () => {
|
const handleCancel = () => {
|
||||||
|
|||||||
@@ -225,8 +225,8 @@ export const v1Routes: RouteObject[] = [
|
|||||||
lazy: () => import("../../routes/products/product-options"),
|
lazy: () => import("../../routes/products/product-options"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "gallery",
|
path: "media",
|
||||||
lazy: () => import("../../routes/products/product-gallery"),
|
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 = {
|
type ThemeContextValue = {
|
||||||
theme: Theme;
|
theme: Theme
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
export const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||||
+21
-17
@@ -1,4 +1,4 @@
|
|||||||
import { PencilSquare, Photo, ThumbnailBadge } from "@medusajs/icons"
|
import { PencilSquare, ThumbnailBadge } from "@medusajs/icons"
|
||||||
import { Product } from "@medusajs/medusa"
|
import { Product } from "@medusajs/medusa"
|
||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -48,10 +48,10 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
|||||||
const res = await prompt({
|
const res = await prompt({
|
||||||
title: t("general.areYouSure"),
|
title: t("general.areYouSure"),
|
||||||
description: includingThumbnail
|
description: includingThumbnail
|
||||||
? t("products.deleteMediaAndThumbnail", {
|
? t("products.media.deleteWarningWithThumbnail", {
|
||||||
count: ids.length,
|
count: ids.length,
|
||||||
})
|
})
|
||||||
: t("products.deleteMedia", {
|
: t("products.media.deleteWarning", {
|
||||||
count: ids.length,
|
count: ids.length,
|
||||||
}),
|
}),
|
||||||
confirmText: t("actions.delete"),
|
confirmText: t("actions.delete"),
|
||||||
@@ -66,30 +66,32 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
|||||||
.filter((i) => !ids.includes(i.id))
|
.filter((i) => !ids.includes(i.id))
|
||||||
.map((i) => i.url)
|
.map((i) => i.url)
|
||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync(
|
||||||
images: mediaToKeep,
|
{
|
||||||
thumbnail: includingThumbnail ? "" : undefined,
|
images: mediaToKeep,
|
||||||
})
|
thumbnail: includingThumbnail ? "" : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelection({})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="divide-y p-0">
|
<Container className="divide-y p-0">
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<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
|
<ActionMenu
|
||||||
groups={[
|
groups={[
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: t("actions.edit"),
|
label: t("actions.edit"),
|
||||||
to: "media",
|
to: "media?view=edit",
|
||||||
icon: <PencilSquare />,
|
icon: <PencilSquare />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("products.gallery"),
|
|
||||||
to: "gallery",
|
|
||||||
icon: <Photo />,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -97,7 +99,9 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
|
|||||||
</div>
|
</div>
|
||||||
{media && (
|
{media && (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-4 px-6 py-4">
|
<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 (
|
return (
|
||||||
<div
|
<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]"
|
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(
|
className={clx(
|
||||||
"transition-fg invisible absolute right-2 top-2 opacity-0 group-hover:visible group-hover:opacity-100",
|
"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>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Link to={`gallery?img=${i.id}`}>
|
<Link to={`media`} state={{ curr: index }}>
|
||||||
<img
|
<img
|
||||||
src={i.url}
|
src={i.url}
|
||||||
alt={`${product.title} image`}
|
alt={`${product.title} image`}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { ProductGallery as Component } from "./product-gallery"
|
|
||||||
-206
@@ -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
|
|
||||||
}
|
|
||||||
+540
@@ -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
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./edit-product-media-form"
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./product-media-gallery"
|
||||||
+310
@@ -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
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./product-media-view"
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { createContext } from "react"
|
||||||
|
|
||||||
|
type ProductMediaViewContextValue = {
|
||||||
|
goToGallery: () => void
|
||||||
|
goToEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductMediaViewContext =
|
||||||
|
createContext<ProductMediaViewContextValue | null>(null)
|
||||||
+55
@@ -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} />
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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"
|
import { clx } from "../../utils/clx"
|
||||||
|
|
||||||
const hintVariants = cva({
|
const hintVariants = cva({
|
||||||
base: "txt-small inline-flex items-start gap-x-2",
|
base: "txt-small",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
info: "text-ui-fg-subtle",
|
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: {
|
defaultVariants: {
|
||||||
|
|||||||
Reference in new issue
Block a user