feat: ui for approve hold request, still need submit

This commit is contained in:
Yehoshua Sandler 2025-04-29 17:23:44 -05:00
parent ebe686b47e
commit 27c29949da
14 changed files with 2349 additions and 28 deletions

915
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,10 @@
"@payloadcms/next": "3.31.0",
"@payloadcms/payload-cloud": "3.31.0",
"@payloadcms/richtext-lexical": "3.31.0",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4",
@ -31,11 +34,12 @@
"@tailwindcss/postcss": "^4.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cross-env": "^7.0.3",
"framer-motion": "^12.7.4",
"graphql": "^16.8.1",
"lucide-react": "^0.488.0",
"motion": "^12.7.4",
"motion": "^12.9.2",
"next": "15.2.3",
"next-themes": "^0.4.6",
"payload": "3.31.0",

View File

@ -12,7 +12,6 @@ const requestHold = async (props: Props) => {
const headers = await nextHeaders()
const authResponse = await payload.auth({ headers })
console.log(authResponse.user);
if (!authResponse.user?.id) return

View File

@ -1,3 +1,4 @@
import { HoldRequest } from "@/payload-types";
import { CollectionConfig } from "payload";
const HoldRequests: CollectionConfig = {

View File

@ -0,0 +1,178 @@
'use client'
import {
Dialog,
DialogDescription,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog'
import { Book, Copy, HoldRequest, Repository, User } from '@/payload-types'
import { findCopiesOfBookInRepository } from '@/serverActions/FindCopiesOnHoldRequest'
import { Variants, Transition } from 'motion/react'
import { useEffect, useState } from 'react'
import { Input } from '../ui/input'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Button } from '../ui/button'
import { Select } from '@headlessui/react'
const formSchema = z
.object({
copyId: z.string(),
untilDate: z.string().date(),
})
.required()
type Props = {
holdRequest: HoldRequest
isOpen: boolean
onOpenChange: () => void
}
const ApproveHoldRequestModal = (props: Props) => {
const { holdRequest, isOpen, onOpenChange } = props
const customVariants: Variants = {
initial: {
scale: 0.9,
filter: 'blur(10px)',
y: '100%',
},
animate: {
scale: 1,
filter: 'blur(0px)',
y: 0,
},
}
const customTransition: Transition = {
type: 'spring',
bounce: 0,
duration: 0.4,
}
const hold = holdRequest as HoldRequest
const book = hold.book as Book
const repository = hold.repository as Repository
const userRequested = hold.userRequested as User
const [availableCopies, setAvailableCopies] = useState<Copy[]>([])
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
copyId: undefined,
untilDate: undefined,
},
})
useEffect(() => {
if (!isOpen || availableCopies.length) return
findCopiesOfBookInRepository({
bookId: book.id,
repositoryId: repository.id,
})
.then(async (response) => {
const copies = response?.docs || []
const availableCopies = copies.filter((c) => !c.holdRequests?.docs?.length)
console.log(availableCopies)
setAvailableCopies(availableCopies)
})
.catch((err) => {
availableCopies
console.error(err)
})
.finally(() => {})
}, [availableCopies, setAvailableCopies, isOpen])
const onSubmit = (values: z.infer<typeof formSchema>) => {
const acceptHoldPayload = {
copyId: parseInt(values.copyId, 10),
untilDate: values.untilDate,
}
console.log(acceptHoldPayload)
onOpenChange()
}
return (
<Dialog
open={isOpen}
onOpenChange={onOpenChange}
variants={customVariants}
transition={customTransition}
>
<DialogContent className="w-full max-w-md bg-white p-6 dark:bg-zinc-900">
<DialogHeader>
<DialogTitle className="text-zinc-900 dark:text-white">
Hold a copy for {`${userRequested.firstName} ${userRequested.lastName}`}?
</DialogTitle>
<DialogDescription className="text-zinc-600 dark:text-zinc-400">
Select which physical copy, and until when, you would like to hold.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-6 flex flex-col space-y-4">
<FormField
control={form.control}
name="copyId"
render={({ field }) => (
<FormItem className="overflow-hidden">
<FormLabel>Copy</FormLabel>
<FormControl>
<Select {...field} className="w-full">
<option>Please Select a Copy</option>
{availableCopies.map((c) => {
console.log(c)
return (
<option key={c.id + '_copy'} value={c.id}>
{c.label || `[${repository.abbreviation}] ${book.title}`}
</option>
)
})}
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="untilDate"
render={({ field }) => (
<FormItem className="sm:w-auto w-full block">
<FormLabel>Until</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
className="mt-4 inline-flex items-center justify-center self-end rounded-lg bg-black px-4 py-2 text-sm font-medium text-zinc-50 dark:bg-white dark:text-zinc-900"
type="submit"
>
Approve Hold
</Button>
</form>
</Form>
<DialogClose />
</DialogContent>
</Dialog>
)
}
export default ApproveHoldRequestModal

View File

@ -1,7 +1,10 @@
import { PaginatedDocs } from 'payload'
import { Author, Book, HoldRequest, Repository } from '@/payload-types'
import { Author, Book, HoldRequest, Repository, User } from '@/payload-types'
import { Button } from '../ui/button'
import Image from 'next/image'
import ApproveHoldRequestModal from './ApproveHoldRequestModal'
import { DialogTrigger } from '../ui/dialog'
import { useState } from 'react'
type Props = {
repos: PaginatedDocs<Repository> | null
@ -9,6 +12,8 @@ type Props = {
const HoldRequestNotifications = (props: Props) => {
const { repos } = props
const [openedModalId, setOpenedModalId] = useState<number | null>(null)
const totalHoldNotifications = repos?.docs.flatMap((r) => r.holdRequests?.docs).length || 0
const holdRequestsByRepoElements = repos?.docs.map((r) => {
@ -18,6 +23,9 @@ const HoldRequestNotifications = (props: Props) => {
const hold = h as HoldRequest
const book = hold.book as Book
const authors = book.authors as Author[]
const dateRequested = hold.dateRequested ? new Date(hold.dateRequested) : new Date()
const userRequested = hold.userRequested as User
return (
<li key={hold.id} className="col-span-1 rounded-lg shadow-sm border border-accent">
<div className="flex w-full items-center justify-between space-x-6 p-6">
@ -37,12 +45,12 @@ const HoldRequestNotifications = (props: Props) => {
</span>
) : (
<span>
<span className="mr-0.5 text-xs">Requested </span>
{!!hold.dateRequested && (
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-amber-500 ring-1 ring-amber-600/20 ring-inset">
{new Date(hold.dateRequested).toLocaleDateString()}
</time>
)}
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-amber-500 ring-1 ring-amber-600/20 ring-inset">
{dateRequested.toLocaleDateString()}
</time>
<span className="text-xs">
{`${userRequested.firstName} ${userRequested.lastName}`}
</span>
</span>
)}
</div>
@ -57,15 +65,24 @@ const HoldRequestNotifications = (props: Props) => {
/>
</div>
<div>
<div className="flex gap-3 justify-around">
<div className="flex gap-2 justify-around">
<Button className="inline-flex flex-1 items-center justify-center gap-3 rounded-bl-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-foreground/5 hover:bg-red-400/10 cursor-pointer hover:scale-105">
<Image width={24} height={24} src="/images/reject.svg" alt="approve hold" />
<span>Decline</span>
</Button>
<Button className="inline-flex flex-1 items-center justify-center gap-3 rounded-br-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-emerald-400/30 hover:bg-emerald-300/60 cursor-pointer hover:scale-105">
<Button
className="inline-flex flex-1 items-center justify-center gap-3 rounded-br-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-emerald-400/30 hover:bg-emerald-300/60 cursor-pointer hover:scale-105"
onClick={() => setOpenedModalId(hold.id)}
>
<Image width={24} height={24} src="/images/approve.svg" alt="approve hold" />
<span>Approve</span>
</Button>
<ApproveHoldRequestModal
isOpen={openedModalId === hold.id}
onOpenChange={setOpenedModalId}
holdRequest={hold}
/>
</div>
</div>
</li>

View File

@ -7,9 +7,11 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useGlobal } from '@/providers/GlobalProvider'
export function LoginForm({ className, ...props }: React.ComponentProps<'div'>) {
const router = useRouter()
const { setUser } = useGlobal()
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@ -38,6 +40,8 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
})
const user = await loginReq.json()
setUser(user.user)
if (user.token) router.push('/')
} catch (error) {
console.error('Login failed:', error)

View File

@ -0,0 +1,76 @@
'use client'
import * as React from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
type Option = {
value: any
label: string
}
type Props = {
options: Option[]
placeholder: string
}
export function Combobox(props: Props) {
const { options, placeholder } = props
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState<any>()
console.log(options)
console.log(open)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{value ? options.find((o) => o.value === value)?.label : placeholder || 'Select Option'}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={placeholder || 'Select Option'} className="h-9" />
<CommandList className="z-50">
<CommandEmpty>No Copies found.</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o.value}
value={o.value}
onSelect={(currentValue: any) => {
setValue(currentValue === value ? undefined : currentValue)
setOpen(false)
}}
>
{o.label}
<Check
className={cn('ml-auto', value === o.value ? 'opacity-100' : 'opacity-0')}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,177 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,335 @@
'use client';
import { AnimatePresence, motion, Transition, Variants } from 'motion/react';
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { useId } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { usePreventScroll } from '@/hooks/usePreventScroll';
const DialogContext = createContext<{
isOpen: boolean;
setIsOpen: (open: boolean) => void;
dialogRef: React.RefObject<HTMLDialogElement | null>;
variants: Variants;
transition?: Transition;
ids: {
dialog: string;
title: string;
description: string;
};
onAnimationComplete: (definition: string) => void;
handleTrigger: () => void;
} | null>(null);
const defaultVariants: Variants = {
initial: {
opacity: 0,
scale: 0.9,
},
animate: {
opacity: 1,
scale: 1,
},
};
const defaultTransition: Transition = {
ease: 'easeOut',
duration: 0.2,
};
export type DialogProps = {
children: React.ReactNode;
variants?: Variants;
transition?: Transition;
className?: string;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
open?: boolean;
};
function Dialog({
children,
variants = defaultVariants,
transition = defaultTransition,
defaultOpen,
onOpenChange,
open,
}: DialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
defaultOpen || false
);
const dialogRef = useRef<HTMLDialogElement>(null);
const isOpen = open !== undefined ? open : uncontrolledOpen;
// prevent scroll when dialog is open on iOS
usePreventScroll({
isDisabled: !isOpen,
});
const setIsOpen = React.useCallback(
(value: boolean) => {
setUncontrolledOpen(value);
onOpenChange?.(value);
},
[onOpenChange]
);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
document.body.classList.add('overflow-hidden');
} else {
document.body.classList.remove('overflow-hidden');
}
const handleCancel = (e: Event) => {
e.preventDefault();
if (isOpen) {
setIsOpen(false);
}
};
dialog.addEventListener('cancel', handleCancel);
return () => {
dialog.removeEventListener('cancel', handleCancel);
document.body.classList.remove('overflow-hidden');
};
}, [dialogRef, isOpen, setIsOpen]);
useEffect(() => {
if (isOpen && dialogRef.current) {
dialogRef.current.showModal();
}
}, [isOpen]);
const handleTrigger = () => {
setIsOpen(true);
};
const onAnimationComplete = (definition: string) => {
if (definition === 'exit' && !isOpen) {
dialogRef.current?.close();
}
};
const baseId = useId();
const ids = {
dialog: `motion-ui-dialog-${baseId}`,
title: `motion-ui-dialog-title-${baseId}`,
description: `motion-ui-dialog-description-${baseId}`,
};
return (
<DialogContext.Provider
value={{
isOpen,
setIsOpen,
dialogRef,
variants,
transition,
ids,
onAnimationComplete,
handleTrigger,
}}
>
{children}
</DialogContext.Provider>
);
}
export type DialogTriggerProps = {
children: React.ReactNode;
className?: string;
};
function DialogTrigger({ children, className }: DialogTriggerProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogTrigger must be used within Dialog');
return (
<button
onClick={context.handleTrigger}
className={cn(
'inline-flex items-center justify-center rounded-md text-sm font-medium',
'transition-colors focus-visible:ring-2 focus-visible:outline-hidden',
'focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
className
)}
>
{children}
</button>
);
}
export type DialogPortalProps = {
children: React.ReactNode;
container?: HTMLElement | null;
};
function DialogPortal({
children,
container = typeof window !== 'undefined' ? document.body : null,
}: DialogPortalProps) {
const [mounted, setMounted] = React.useState(false);
const [portalContainer, setPortalContainer] =
React.useState<HTMLElement | null>(null);
useEffect(() => {
setMounted(true);
setPortalContainer(container || document.body);
return () => setMounted(false);
}, [container]);
if (!mounted || !portalContainer) {
return null;
}
return createPortal(children, portalContainer);
}
export type DialogContentProps = {
children: React.ReactNode;
className?: string;
container?: HTMLElement;
};
function DialogContent({ children, className, container }: DialogContentProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogContent must be used within Dialog');
const {
isOpen,
setIsOpen,
dialogRef,
variants,
transition,
ids,
onAnimationComplete,
} = context;
const content = (
<AnimatePresence mode='wait'>
{isOpen && (
<motion.dialog
key={ids.dialog}
ref={dialogRef as React.RefObject<HTMLDialogElement>}
id={ids.dialog}
aria-labelledby={ids.title}
aria-describedby={ids.description}
aria-modal='true'
role='dialog'
onClick={(e) => {
if (e.target === dialogRef.current) {
setIsOpen(false);
}
}}
initial='initial'
animate='animate'
exit='exit'
variants={variants}
transition={transition}
onAnimationComplete={onAnimationComplete}
className={cn(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform rounded-lg border border-zinc-200 p-0 shadow-lg dark:border dark:border-zinc-700',
'backdrop:bg-black/50 backdrop:backdrop-blur-xs',
'open:flex open:flex-col',
className
)}
>
<div className='w-full'>{children}</div>
</motion.dialog>
)}
</AnimatePresence>
);
return <DialogPortal container={container}>{content}</DialogPortal>;
}
export type DialogHeaderProps = {
children: React.ReactNode;
className?: string;
};
function DialogHeader({ children, className }: DialogHeaderProps) {
return (
<div className={cn('flex flex-col space-y-1.5', className)}>{children}</div>
);
}
export type DialogTitleProps = {
children: React.ReactNode;
className?: string;
};
function DialogTitle({ children, className }: DialogTitleProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogTitle must be used within Dialog');
return (
<h2
id={context.ids.title}
className={cn('text-base font-medium', className)}
>
{children}
</h2>
);
}
export type DialogDescriptionProps = {
children: React.ReactNode;
className?: string;
};
function DialogDescription({ children, className }: DialogDescriptionProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogDescription must be used within Dialog');
return (
<p
id={context.ids.description}
className={cn('text-base text-zinc-500', className)}
>
{children}
</p>
);
}
export type DialogCloseProps = {
className?: string;
children?: React.ReactNode;
disabled?: boolean;
};
function DialogClose({ className, children, disabled }: DialogCloseProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogClose must be used within Dialog');
return (
<button
onClick={() => context.setIsOpen(false)}
type='button'
aria-label='Close dialog'
className={cn(
'absolute top-4 right-4 rounded-xs opacity-70 transition-opacity',
'hover:opacity-100 focus:ring-2 focus:outline-hidden',
'focus:ring-zinc-500 focus:ring-offset-2 disabled:pointer-events-none',
className
)}
disabled={disabled}
>
{children || <X className='h-4 w-4' />}
<span className='sr-only'>Close</span>
</button>
);
}
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogClose,
};

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,360 @@
import { useEffect, useLayoutEffect } from 'react';
function isMac(): boolean | undefined {
return testPlatform(/^Mac/);
}
function isIPhone(): boolean | undefined {
return testPlatform(/^iPhone/);
}
function isIPad(): boolean | undefined {
return (
testPlatform(/^iPad/) ||
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
(isMac() && navigator.maxTouchPoints > 1)
);
}
function isIOS(): boolean | undefined {
return isIPhone() || isIPad();
}
function testPlatform(re: RegExp): boolean | undefined {
return typeof window !== 'undefined' && window.navigator != null
? re.test(window.navigator.platform)
: undefined;
}
const KEYBOARD_BUFFER = 24;
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
interface PreventScrollOptions {
/** Whether the scroll lock is disabled. */
isDisabled?: boolean;
focusCallback?: () => void;
}
function chain(...callbacks: any[]): (...args: any[]) => void {
return (...args: any[]) => {
for (let callback of callbacks) {
if (typeof callback === 'function') {
callback(...args);
}
}
};
}
// @ts-ignore
const visualViewport = typeof document !== 'undefined' && window.visualViewport;
export function isScrollable(node: Element): boolean {
let style = window.getComputedStyle(node);
return /(auto|scroll)/.test(
style.overflow + style.overflowX + style.overflowY
);
}
export function getScrollParent(node: Element): Element {
if (isScrollable(node)) {
node = node.parentElement as HTMLElement;
}
while (node && !isScrollable(node)) {
node = node.parentElement as HTMLElement;
}
return node || document.scrollingElement || document.documentElement;
}
// HTML input types that do not cause the software keyboard to appear.
const nonTextInputTypes = new Set([
'checkbox',
'radio',
'range',
'color',
'file',
'image',
'button',
'submit',
'reset',
]);
// The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position
let preventScrollCount = 0;
let restore: () => void;
/**
* Prevents scrolling on the document body on mount, and
* restores it on unmount. Also ensures that content does not
* shift due to the scrollbars disappearing.
*/
export function usePreventScroll(options: PreventScrollOptions = {}) {
let { isDisabled } = options;
useIsomorphicLayoutEffect(() => {
if (isDisabled) {
return;
}
preventScrollCount++;
if (preventScrollCount === 1) {
if (isIOS()) {
restore = preventScrollMobileSafari();
}
}
return () => {
preventScrollCount--;
if (preventScrollCount === 0) {
restore?.();
}
};
}, [isDisabled]);
}
// Mobile Safari is a whole different beast. Even with overflow: hidden,
// it still scrolls the page in many situations:
//
// 1. When the bottom toolbar and address bar are collapsed, page scrolling is always allowed.
// 2. When the keyboard is visible, the viewport does not resize. Instead, the keyboard covers part of
// it, so it becomes scrollable.
// 3. When tapping on an input, the page always scrolls so that the input is centered in the visual viewport.
// This may cause even fixed position elements to scroll off the screen.
// 4. When using the next/previous buttons in the keyboard to navigate between inputs, the whole page always
// scrolls, even if the input is inside a nested scrollable element that could be scrolled instead.
//
// In order to work around these cases, and prevent scrolling without jankiness, we do a few things:
//
// 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling
// on the window.
// 2. Prevent default on `touchmove` events inside a scrollable element when the scroll position is at the
// top or bottom. This avoids the whole page scrolling instead, but does prevent overscrolling.
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
// 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top
// of the page, which prevents it from scrolling the page. After the input is focused, scroll the element
// into view ourselves, without scrolling the whole page.
// 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the
// same visually, but makes the actual scroll position always zero. This is required to make all of the
// above work or Safari will still try to scroll the page when focusing an input.
// 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting
// to navigate to an input with the next/previous buttons that's outside a modal.
function preventScrollMobileSafari() {
let scrollable: Element;
let lastY = 0;
let onTouchStart = (e: TouchEvent) => {
// Store the nearest scrollable parent element from the element that the user touched.
scrollable = getScrollParent(e.target as Element);
if (
scrollable === document.documentElement &&
scrollable === document.body
) {
return;
}
lastY = e.changedTouches[0].pageY;
};
let onTouchMove = (e: TouchEvent) => {
// Prevent scrolling the window.
if (
!scrollable ||
scrollable === document.documentElement ||
scrollable === document.body
) {
e.preventDefault();
return;
}
// Prevent scrolling up when at the top and scrolling down when at the bottom
// of a nested scrollable area, otherwise mobile Safari will start scrolling
// the window instead. Unfortunately, this disables bounce scrolling when at
// the top but it's the best we can do.
let y = e.changedTouches[0].pageY;
let scrollTop = scrollable.scrollTop;
let bottom = scrollable.scrollHeight - scrollable.clientHeight;
if (bottom === 0) {
return;
}
if ((scrollTop <= 0 && y > lastY) || (scrollTop >= bottom && y < lastY)) {
e.preventDefault();
}
lastY = y;
};
let onTouchEnd = (e: TouchEvent) => {
let target = e.target as HTMLElement;
// Apply this change if we're not already focused on the target element
if (isInput(target) && target !== document.activeElement) {
e.preventDefault();
// Apply a transform to trick Safari into thinking the input is at the top of the page
// so it doesn't try to scroll it into view. When tapping on an input, this needs to
// be done before the "focus" event, so we have to focus the element ourselves.
target.style.transform = 'translateY(-2000px)';
target.focus();
requestAnimationFrame(() => {
target.style.transform = '';
});
}
};
let onFocus = (e: FocusEvent) => {
let target = e.target as HTMLElement;
if (isInput(target)) {
// Transform also needs to be applied in the focus event in cases where focus moves
// other than tapping on an input directly, e.g. the next/previous buttons in the
// software keyboard. In these cases, it seems applying the transform in the focus event
// is good enough, whereas when tapping an input, it must be done before the focus event. 🤷‍♂️
target.style.transform = 'translateY(-2000px)';
requestAnimationFrame(() => {
target.style.transform = '';
// This will have prevented the browser from scrolling the focused element into view,
// so we need to do this ourselves in a way that doesn't cause the whole page to scroll.
if (visualViewport) {
if (visualViewport.height < window.innerHeight) {
// If the keyboard is already visible, do this after one additional frame
// to wait for the transform to be removed.
requestAnimationFrame(() => {
scrollIntoView(target);
});
} else {
// Otherwise, wait for the visual viewport to resize before scrolling so we can
// measure the correct position to scroll to.
visualViewport.addEventListener(
'resize',
() => scrollIntoView(target),
{ once: true }
);
}
}
});
}
};
let onWindowScroll = () => {
// Last resort. If the window scrolled, scroll it back to the top.
// It should always be at the top because the body will have a negative margin (see below).
window.scrollTo(0, 0);
};
// Record the original scroll position so we can restore it.
// Then apply a negative margin to the body to offset it by the scroll position. This will
// enable us to scroll the window to the top, which is required for the rest of this to work.
let scrollX = window.pageXOffset;
let scrollY = window.pageYOffset;
let restoreStyles = chain(
setStyle(
document.documentElement,
'paddingRight',
`${window.innerWidth - document.documentElement.clientWidth}px`
)
// setStyle(document.documentElement, 'overflow', 'hidden'),
// setStyle(document.body, 'marginTop', `-${scrollY}px`),
);
// Scroll to the top. The negative margin on the body will make this appear the same.
window.scrollTo(0, 0);
let removeEvents = chain(
addEvent(document, 'touchstart', onTouchStart, {
passive: false,
capture: true,
}),
addEvent(document, 'touchmove', onTouchMove, {
passive: false,
capture: true,
}),
addEvent(document, 'touchend', onTouchEnd, {
passive: false,
capture: true,
}),
addEvent(document, 'focus', onFocus, true),
addEvent(window, 'scroll', onWindowScroll)
);
return () => {
// Restore styles and scroll the page back to where it was.
restoreStyles();
removeEvents();
window.scrollTo(scrollX, scrollY);
};
}
// Sets a CSS property on an element, and returns a function to revert it to the previous value.
function setStyle(
element: HTMLElement,
style: keyof React.CSSProperties,
value: string
) {
// https://github.com/microsoft/TypeScript/issues/17827#issuecomment-391663310
// @ts-ignore
let cur = element.style[style];
// @ts-ignore
element.style[style] = value;
return () => {
// @ts-ignore
element.style[style] = cur;
};
}
// Adds an event listener to an element, and returns a function to remove it.
function addEvent<K extends keyof GlobalEventHandlersEventMap>(
target: EventTarget,
event: K,
handler: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
) {
// @ts-ignore
target.addEventListener(event, handler, options);
return () => {
// @ts-ignore
target.removeEventListener(event, handler, options);
};
}
function scrollIntoView(target: Element) {
let root = document.scrollingElement || document.documentElement;
while (target && target !== root) {
// Find the parent scrollable element and adjust the scroll position if the target is not already in view.
let scrollable = getScrollParent(target);
if (
scrollable !== document.documentElement &&
scrollable !== document.body &&
scrollable !== target
) {
let scrollableTop = scrollable.getBoundingClientRect().top;
let targetTop = target.getBoundingClientRect().top;
let targetBottom = target.getBoundingClientRect().bottom;
// Buffer is needed for some edge cases
const keyboardHeight =
scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER;
if (targetBottom > keyboardHeight) {
scrollable.scrollTop += targetTop - scrollableTop;
}
}
// @ts-ignore
target = scrollable.parentElement;
}
}
export function isInput(target: Element) {
return (
(target instanceof HTMLInputElement &&
!nonTextInputTypes.has(target.type)) ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
}

View File

@ -0,0 +1,56 @@
'use server'
import { getPayload, PaginatedDocs } from 'payload'
import config from '@/payload.config'
import { Copy } from '@/payload-types'
type Props = {
bookId: number,
repositoryId: number
}
export const findCopiesOfBookInRepository = async (props: Props): Promise<PaginatedDocs<Copy> | null> => {
const { bookId, repositoryId } = props
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
try {
const findResponse = (await payload.find({
collection: 'copies',
depth: 2,
limit: 25,
overrideAccess: false,
where: {
and: [
{
'repository.id': {
equals: repositoryId
}
},
{
'book.id': {
equals: bookId
}
},
]
},
select: {
id: true,
label: true,
repository: true,
book: true,
holdRequests: true,
},
joins: {
holdRequests: {
limit: 100,
},
}
})) as PaginatedDocs<Copy>
return findResponse
} catch (err) {
console.log(err)
return null
}
}