diff --git a/package-lock.json b/package-lock.json index 27ac9ec..f137671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "cmdk": "^0.2.0", "content-disposition": "^0.5.4", "dayjs": "^1.11.10", + "embla-carousel-react": "^8.0.0-rc21", "lucide-react": "^0.290.0", "nanoid": "^5.0.4", "next": "^14.1.0", @@ -4867,6 +4868,31 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.0.0-rc21", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.0.0-rc21.tgz", + "integrity": "sha512-rK//vyPIhmD/5QUDtjk9A5RxPoDZ5LOATYMVSFECAzwcAe7yJmqXQbdYzEZf4ASOR+ivod5msqXsKgZXypA35Q==" + }, + "node_modules/embla-carousel-react": { + "version": "8.0.0-rc21", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.0.0-rc21.tgz", + "integrity": "sha512-DOa9hgF/T1fwb8D3rZ8FFMceY3aDXtbluZwzZYMLnN2Dqn0IBLN0l97o3obkMxI9Zzog0u1WMM6HE7AGF9SjEg==", + "dependencies": { + "embla-carousel": "8.0.0-rc21", + "embla-carousel-reactive-utils": "8.0.0-rc21" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.0.0-rc21", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.0.0-rc21.tgz", + "integrity": "sha512-TnV49hoTdwfcKr2vgQHQ3zcCqJSkvLJ5rR/pGzmEx5GeO07CV/e755lkSD7No0C6cz+JFB8dcHV7uS+5Gnc7Lg==", + "peerDependencies": { + "embla-carousel": "8.0.0-rc21" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/package.json b/package.json index 40094b3..819f815 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "cmdk": "^0.2.0", "content-disposition": "^0.5.4", "dayjs": "^1.11.10", + "embla-carousel-react": "^8.0.0-rc21", "lucide-react": "^0.290.0", "nanoid": "^5.0.4", "next": "^14.1.0", diff --git a/src/components/expense-documents-input.tsx b/src/components/expense-documents-input.tsx index 11e5708..80b78f7 100644 --- a/src/components/expense-documents-input.tsx +++ b/src/components/expense-documents-input.tsx @@ -1,4 +1,12 @@ import { Button } from '@/components/ui/button' +import { + Carousel, + CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel' import { Dialog, DialogClose, @@ -12,7 +20,7 @@ import { ExpenseFormValues } from '@/lib/schemas' import { Loader2, Plus, Trash, X } from 'lucide-react' import { getImageData, useS3Upload } from 'next-s3-upload' import Image from 'next/image' -import { useState } from 'react' +import { useEffect, useState } from 'react' type Props = { documents: ExpenseFormValues['documents'] @@ -61,8 +69,9 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) { { - updateDocuments(documents.filter((d) => d.id !== doc.id)) + documents={documents} + deleteDocument={(document) => { + updateDocuments(documents.filter((d) => d.id !== document.id)) }} /> ))} @@ -89,12 +98,27 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) { export function DocumentThumbnail({ document, + documents, deleteDocument, }: { document: ExpenseFormValues['documents'][number] - deleteDocument: () => void + documents: ExpenseFormValues['documents'] + deleteDocument: (document: ExpenseFormValues['documents'][number]) => void }) { const [open, setOpen] = useState(false) + const [api, setApi] = useState() + const [currentDocument, setCurrentDocument] = useState(null) + + useEffect(() => { + if (!api) return + + api.on('slidesInView', () => { + const index = api.slidesInView()[0] + if (index !== undefined) { + setCurrentDocument(index) + } + }) + }, [api]) return ( @@ -112,33 +136,54 @@ export function DocumentThumbnail({ /> - -
- - - - + + + +
+ + + + {documents.map((document, index) => ( + + + + ))} + + + + - {/* eslint-disable-next-line @next/next/no-img-element */} -
) diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..ec505d0 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( +
+ ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}