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:
Kasper Fabricius Kristensen
2024-03-25 12:22:30 +01:00
committed by GitHub
parent 1b2e0b4031
commit 247ca3c3fa
19 changed files with 1016 additions and 242 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/ui": patch
---
fix(ui): Ensure that Hint's with variant 'error' are not squished when constrained.

View File

@@ -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.",

View File

@@ -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 = () => {

View File

@@ -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"),
},
],
},

View File

@@ -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)

View File

@@ -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`}

View File

@@ -1 +0,0 @@
export { ProductGallery as Component } from "./product-gallery"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1 @@
export * from "./edit-product-media-form"

View File

@@ -0,0 +1 @@
export * from "./product-media-gallery"

View File

@@ -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
}

View File

@@ -0,0 +1 @@
export * from "./product-media-view"

View File

@@ -0,0 +1,9 @@
import { createContext } from "react"
type ProductMediaViewContextValue = {
goToGallery: () => void
goToEdit: () => void
}
export const ProductMediaViewContext =
createContext<ProductMediaViewContextValue | null>(null)

View File

@@ -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} />
}
}

View File

@@ -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
}

View File

@@ -0,0 +1 @@
export { ProductMedia as Component } from "./product-media"

View File

@@ -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>
)
}

View File

@@ -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: {