diff --git a/.changeset/curvy-coins-stare.md b/.changeset/curvy-coins-stare.md new file mode 100644 index 0000000000..49a32c00b9 --- /dev/null +++ b/.changeset/curvy-coins-stare.md @@ -0,0 +1,5 @@ +--- +"@medusajs/ui": patch +--- + +fix(ui): Ensure that Hint's with variant 'error' are not squished when constrained. diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index eeaaa461a1..14283bf87d 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -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.", diff --git a/packages/admin-next/dashboard/src/components/route-modal/route-form/route-form.tsx b/packages/admin-next/dashboard/src/components/route-modal/route-form/route-form.tsx index d3131caac3..d008ae115c 100644 --- a/packages/admin-next/dashboard/src/components/route-modal/route-form/route-form.tsx +++ b/packages/admin-next/dashboard/src/components/route-modal/route-form/route-form.tsx @@ -7,10 +7,12 @@ import { Form } from "../../common/form" type RouteFormProps = PropsWithChildren<{ form: UseFormReturn + blockSearch?: boolean }> export const RouteForm = ({ form, + blockSearch = false, children, }: RouteFormProps) => { const { t } = useTranslation() @@ -26,7 +28,14 @@ export const RouteForm = ({ 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 = () => { diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx index 62ba66137d..8938eed7c3 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx @@ -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"), }, ], }, diff --git a/packages/admin-next/dashboard/src/providers/theme-provider/theme-context.tsx b/packages/admin-next/dashboard/src/providers/theme-provider/theme-context.tsx index bcb55c6823..ae55b1cfda 100644 --- a/packages/admin-next/dashboard/src/providers/theme-provider/theme-context.tsx +++ b/packages/admin-next/dashboard/src/providers/theme-provider/theme-context.tsx @@ -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(null); +export const ThemeContext = createContext(null) diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx index 7cc39e0621..330f16b278 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-media-section/product-media-section.tsx @@ -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 (
- {t("products.media")} + {t("products.media.label")} , }, - { - label: t("products.gallery"), - to: "gallery", - icon: , - }, ], }, ]} @@ -97,7 +99,9 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
{media && (
- {media.map((i) => { + {media.map((i, index) => { + const isSelected = selection[i.id] + return (
{ 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) => {
)} - + {`${product.title} { - 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 ( - - - -
-
- - - - - - esc -
-
- - - - - - - - - - - - - -
-
-
-
-
- {media[currentIndex].isThumbnail && ( -
- - - -
- )} - -
-
- - paginate(-1)} - className="absolute left-4 top-1/2 z-[2] rounded-full" - > - - - paginate(1)} - className="absolute right-4 top-1/2 z-[2] rounded-full" - > - - -
-
- {media.map((img, index) => ( - - ))} -
-
-
-
- ) -} - -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 -} diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx new file mode 100644 index 0000000000..6c3aa51c91 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/edit-product-media-form.tsx @@ -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 + +const EditProductMediaSchema = z.object({ + media: z.array(MediaSchema), +}) + +export const EditProductMediaForm = ({ product }: ProductMediaViewProps) => { + const [selection, setSelection] = useState>({}) + const [isDragOver, setIsDragOver] = useState(false) + + const dropZoneRef = useRef(null) + const inputRef = useRef(null) + + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + 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) => { + 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 ( + +
+ +
+ + + + + +
+
+ +
+
+
+ {fields.map((m) => { + return ( + + ) + })} +
+
+
+ { + return ( + +
+
+ + {t("products.media.label")} + + {t("products.media.editHint")} +
+ +
+ + +
+
+ +
+
+ ) + }} + /> +
+
+
+ + + + {t("general.countSelected", { + count: selectionCount, + })} + + + {selectionCount === 1 && ( + + + + + )} + + + +
+
+ ) +} + +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 ( + + ) +} + +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 +} diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/index.ts b/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/index.ts new file mode 100644 index 0000000000..725f426a2b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/edit-product-media-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-product-media-form" diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-gallery/index.ts b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-gallery/index.ts new file mode 100644 index 0000000000..254191a053 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-gallery/index.ts @@ -0,0 +1 @@ +export * from "./product-media-gallery" diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx new file mode 100644 index 0000000000..f75caeb570 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-gallery/product-media-gallery.tsx @@ -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(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 ( +
+ +
+ + + + {t("products.media.deleteImageLabel")} + + + + + + {t("products.media.downloadImageLabel")} + + + +
+
+ + + + +
+ ) +} + +const Canvas = ({ media, curr }: { media: Media[]; curr: number }) => { + const { t } = useTranslation() + + if (media.length === 0) { + return ( +
+ + {t("products.media.noMediaLabel")} +
+ ) + } + + return ( +
+
+
+ {media[curr].isThumbnail && ( +
+ + + +
+ )} + +
+
+
+ ) +} + +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 ( +
+ + + +
+ {visibleItems.map((item) => { + const isCurrentImage = item.id === media[curr].id + const originalIndex = media.findIndex((i) => i.id === item.id) + + return ( + + ) + })} +
+ + + +
+ ) +} + +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 +} diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/index.ts b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/index.ts new file mode 100644 index 0000000000..da348356f9 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/index.ts @@ -0,0 +1 @@ +export * from "./product-media-view" diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view-context.tsx b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view-context.tsx new file mode 100644 index 0000000000..c1eda4eb33 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view-context.tsx @@ -0,0 +1,9 @@ +import { createContext } from "react" + +type ProductMediaViewContextValue = { + goToGallery: () => void + goToEdit: () => void +} + +export const ProductMediaViewContext = + createContext(null) diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view.tsx b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view.tsx new file mode 100644 index 0000000000..98d2568365 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/product-media-view.tsx @@ -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 ( + + {renderView(view, product)} + + ) +} + +const renderView = (view: View, product: Product) => { + switch (view) { + case View.GALLERY: + return + case View.EDIT: + return + } +} diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/use-product-media-view.tsx b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/use-product-media-view.tsx new file mode 100644 index 0000000000..0e513d0d6d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/components/product-media-view/use-product-media-view.tsx @@ -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 +} diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/index.ts b/packages/admin-next/dashboard/src/routes/products/product-media/index.ts new file mode 100644 index 0000000000..d6150b838a --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/index.ts @@ -0,0 +1 @@ +export { ProductMedia as Component } from "./product-media" diff --git a/packages/admin-next/dashboard/src/routes/products/product-media/product-media.tsx b/packages/admin-next/dashboard/src/routes/products/product-media/product-media.tsx new file mode 100644 index 0000000000..71f47a2ebe --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-media/product-media.tsx @@ -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 ( + + {ready && } + + ) +} diff --git a/packages/design-system/ui/src/components/hint/hint.tsx b/packages/design-system/ui/src/components/hint/hint.tsx index 2e5dec584b..0a7a003d60 100644 --- a/packages/design-system/ui/src/components/hint/hint.tsx +++ b/packages/design-system/ui/src/components/hint/hint.tsx @@ -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: {