aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/expense-documents-input.tsx101
-rw-r--r--src/components/ui/carousel.tsx262
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,
+}