diff options
| author | Sebastien Castiel <sebastien@castiel.me> | 2024-01-28 23:28:21 -0500 |
|---|---|---|
| committer | Sebastien Castiel <sebastien@castiel.me> | 2024-01-28 23:28:44 -0500 |
| commit | 9876d7045fa13f5ba0d0f05846693a0313eff13d (patch) | |
| tree | 819e103e3e3cdb9f4afaa645035ae9924fc6de8e /src | |
| parent | 9759f61e0e77fbd888e91f22c5dce14854830a1b (diff) | |
Use carousel to display images
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/expense-documents-input.tsx | 101 | ||||
| -rw-r--r-- | src/components/ui/carousel.tsx | 262 |
2 files changed, 335 insertions, 28 deletions
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,5 +1,13 @@ import { Button } from '@/components/ui/button' import { + Carousel, + CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel' +import { Dialog, DialogClose, DialogContent, @@ -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) { <DocumentThumbnail key={doc.id} document={doc} - deleteDocument={() => { - 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<CarouselApi>() + const [currentDocument, setCurrentDocument] = useState<number | null>(null) + + useEffect(() => { + if (!api) return + + api.on('slidesInView', () => { + const index = api.slidesInView()[0] + if (index !== undefined) { + setCurrentDocument(index) + } + }) + }, [api]) return ( <Dialog open={open} onOpenChange={setOpen}> @@ -112,33 +136,54 @@ export function DocumentThumbnail({ /> </Button> </DialogTrigger> - <DialogContent className="p-4 w-fit min-w-[300px] min-h-[300px] max-w-full [&>:last-child]:hidden"> - <div className="flex justify-end"> - <Button - variant="ghost" - className="text-destructive" - onClick={() => { - deleteDocument() - setOpen(false) + <DialogContent className="p-4 w-[100vw] max-w-[100vw] h-[100dvh] max-h-[100dvh] sm:max-w-[calc(100vw-32px)] sm:max-h-[calc(100dvh-32px)] [&>:last-child]:hidden"> + <div className="flex flex-col gap-4"> + <div className="flex justify-end"> + <Button + variant="ghost" + className="text-destructive" + onClick={() => { + if (currentDocument !== null) { + deleteDocument(documents[currentDocument]) + } + setOpen(false) + }} + > + <Trash className="w-4 h-4 mr-2" /> + Delete document + </Button> + <DialogClose asChild> + <Button variant="ghost"> + <X className="w-4 h-4 mr-2" /> Close + </Button> + </DialogClose> + </div> + + <Carousel + opts={{ + startIndex: documents.indexOf(document), + loop: true, + align: 'center', }} + setApi={setApi} > - <Trash className="w-4 h-4 mr-2" /> - Delete document - </Button> - <DialogClose asChild> - <Button variant="ghost"> - <X className="w-4 h-4 mr-2" /> Close - </Button> - </DialogClose> + <CarouselContent> + {documents.map((document, index) => ( + <CarouselItem key={index}> + <Image + className="object-contain w-[calc(100vw-32px)] h-[calc(100dvh-32px-40px-16px)] sm:w-[calc(100vw-32px-32px)] sm:h-[calc(100dvh-32px-40px-16px-32px)]" + src={document.url} + width={document.width} + height={document.height} + alt="" + /> + </CarouselItem> + ))} + </CarouselContent> + <CarouselPrevious className="left-0 top-auto bottom-0" /> + <CarouselNext className="right-0 top-auto bottom-0" /> + </Carousel> </div> - {/* eslint-disable-next-line @next/next/no-img-element */} - <Image - className="object-contain w-[100vw] h-[100dvh] max-w-[calc(100vw-32px)] max-h-[calc(100dvh-32px-40px-16px)] sm:w-fit sm:h-fit sm:max-w-[calc(100vw-32px-32px)] sm:max-h-[calc(100dvh-32px-40px-32px)]" - src={document.url} - width={document.width} - height={document.height} - alt="" - /> </DialogContent> </Dialog> ) 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<typeof useEmblaCarousel> +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<typeof useEmblaCarousel>[0] + api: ReturnType<typeof useEmblaCarousel>[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext<CarouselContextProps | null>(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & 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<HTMLDivElement>) => { + 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 ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn("relative", className)} + role="region" + aria-roledescription="carousel" + {...props} + > + {children} + </div> + </CarouselContext.Provider> + ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( + <div ref={carouselRef} className="overflow-hidden"> + <div + ref={ref} + className={cn( + "flex", + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", + className + )} + {...props} + /> + </div> + ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn( + "min-w-0 shrink-0 grow-0 basis-full", + orientation === "horizontal" ? "pl-4" : "pt-4", + className + )} + {...props} + /> + ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-left-12 top-1/2 -translate-y-1/2" + : "-top-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props} + > + <ArrowLeft className="h-4 w-4" /> + <span className="sr-only">Previous slide</span> + </Button> + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-right-12 top-1/2 -translate-y-1/2" + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollNext} + onClick={scrollNext} + {...props} + > + <ArrowRight className="h-4 w-4" /> + <span className="sr-only">Next slide</span> + </Button> + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} |
