feat: ui for approve hold request, still need submit
This commit is contained in:
parent
ebe686b47e
commit
27c29949da
915
package-lock.json
generated
915
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { HoldRequest } from "@/payload-types";
|
||||
import { CollectionConfig } from "payload";
|
||||
|
||||
const HoldRequests: CollectionConfig = {
|
||||
|
||||
178
src/components/Manage/ApproveHoldRequestModal.tsx
Normal file
178
src/components/Manage/ApproveHoldRequestModal.tsx
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
76
src/components/ui/combobox.tsx
Normal file
76
src/components/ui/combobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
src/components/ui/command.tsx
Normal file
177
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
335
src/components/ui/dialog.tsx
Normal file
335
src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal 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 }
|
||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
360
src/hooks/usePreventScroll.tsx
Normal file
360
src/hooks/usePreventScroll.tsx
Normal 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)
|
||||
);
|
||||
}
|
||||
56
src/serverActions/FindCopiesOnHoldRequest.ts
Normal file
56
src/serverActions/FindCopiesOnHoldRequest.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user