feat: search books by author and title
This commit is contained in:
parent
7a7b85f8c5
commit
6d32990cb6
65
package-lock.json
generated
65
package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.1",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@payloadcms/db-postgres": "3.31.0",
|
||||
"@payloadcms/next": "3.31.0",
|
||||
"@payloadcms/payload-cloud": "3.31.0",
|
||||
@ -32,9 +33,12 @@
|
||||
"payload": "3.31.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"sharp": "0.32.6",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@ -2519,6 +2523,18 @@
|
||||
"react": ">= 16 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz",
|
||||
"integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@ -4040,6 +4056,16 @@
|
||||
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
|
||||
}
|
||||
},
|
||||
"node_modules/@payloadcms/ui/node_modules/sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
||||
@ -5196,6 +5222,12 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
@ -12174,6 +12206,22 @@
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.56.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz",
|
||||
"integrity": "sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-image-crop": {
|
||||
"version": "10.1.8",
|
||||
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-10.1.8.tgz",
|
||||
@ -12784,9 +12832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz",
|
||||
"integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
@ -14338,6 +14386,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
|
||||
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.1",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@payloadcms/db-postgres": "3.31.0",
|
||||
"@payloadcms/next": "3.31.0",
|
||||
"@payloadcms/payload-cloud": "3.31.0",
|
||||
@ -39,9 +40,12 @@
|
||||
"payload": "3.31.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"sharp": "0.32.6",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
@ -2,7 +2,6 @@ import { headers as nextHeaders } from 'next/headers'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { GalleryVerticalEnd } from 'lucide-react'
|
||||
import { LoginForm } from '@/components/login-form'
|
||||
import Image from 'next/image'
|
||||
|
||||
@ -16,7 +15,10 @@ const LoginPage = async (props: Props) => {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<a href="https://beitzah.net?ref=midrashim" className="flex items-center gap-2 self-center font-medium">
|
||||
<a
|
||||
href="https://beitzah.net?ref=midrashim"
|
||||
className="flex items-center gap-2 self-center font-medium"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<Image
|
||||
src="https://cdn.beitzah.net/egg-highlight-white.svg"
|
||||
@ -27,8 +29,8 @@ const LoginPage = async (props: Props) => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block leading-3.5">Developed by</span>
|
||||
<span className="block leading-3.5">Beitzah.ts</span>
|
||||
<span className="block leading-3.5">Developed with 💜</span>
|
||||
<span className="block leading-3.5">Beitzah.tech</span>
|
||||
</div>
|
||||
</a>
|
||||
<LoginForm />
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { headers as getHeaders } from 'next/headers.js'
|
||||
import { getPayload, PaginatedDocs } from 'payload'
|
||||
import config from '@/payload.config'
|
||||
import React from 'react'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import BookList from '@/components/BookList'
|
||||
import UserFeed from '@/components/Feed/UserFeed'
|
||||
import { Book } from '@/payload-types'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { TextShimmer } from '@/components/ui/text-shimmer'
|
||||
import { LoginForm } from '@/components/login-form'
|
||||
import SearchBooks from '@/components/Search/SearchBooks'
|
||||
|
||||
export default async function HomePage() {
|
||||
const headers = await getHeaders()
|
||||
@ -36,7 +38,7 @@ export default async function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<div className="relative isolate overflow-hidden py-24 sm:py-32">
|
||||
<div className="relative isolate overflow-hidden py-24 sm:py-32 rounded-md">
|
||||
<img
|
||||
alt=""
|
||||
src="/api/media/file/geniza1.jpg"
|
||||
@ -80,7 +82,9 @@ export default async function HomePage() {
|
||||
>
|
||||
Welcome
|
||||
</TextShimmer>
|
||||
{user && <small>{user.firstName}</small>}
|
||||
<span className="text-shadow-lg text-shadow-background">
|
||||
{user ? <small>{user.firstName}</small> : <small>In</small>}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="mt-8 text-lg font-normal text-pretty text-foreground text-shadow-lg text-shadow-background sm:text-xl/8">
|
||||
<p className="indent-5 italic">
|
||||
@ -94,21 +98,27 @@ export default async function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="feed" className="p-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="feed">Your Feed</TabsTrigger>
|
||||
<TabsTrigger value="browse">Browse</TabsTrigger>
|
||||
<TabsTrigger value="search">Search</TabsTrigger>
|
||||
</TabsList>
|
||||
{user ? (
|
||||
<Tabs defaultValue="feed" className="p-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="feed">Your Feed</TabsTrigger>
|
||||
<TabsTrigger value="search">Search</TabsTrigger>
|
||||
<TabsTrigger value="yourRepos">Your Repos</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="feed">{user && <UserFeed user={user} />}</TabsContent>
|
||||
<TabsContent value="feed">{user && <UserFeed user={user} />}</TabsContent>
|
||||
|
||||
<TabsContent value="browse">
|
||||
{initBrowseBooks && <BookList books={initBrowseBooks} />}
|
||||
</TabsContent>
|
||||
<TabsContent value="search">
|
||||
<SearchBooks initBrowseBooks={initBrowseBooks} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="search">Search</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="yourRepos">Your Repos</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="flex w-full max-w-sm flex-col gap-6 mx-auto my-6">
|
||||
<LoginForm />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -5,11 +5,13 @@ type LayoutProps = {
|
||||
}
|
||||
|
||||
import './globals.css'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
<Toaster />
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import { Author, Book, Genre } from '@/payload-types'
|
||||
import { Avatar } from '../avatar'
|
||||
import { PaginatedDocs } from 'payload'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
type Props = {
|
||||
books: PaginatedDocs<Book>
|
||||
@ -37,7 +38,7 @@ const makeAuthorsLabel = (book: Book) => {
|
||||
|
||||
const makeGenreBadges = (book: Book) => {
|
||||
return (book.genre as Genre[])?.map((g) => (
|
||||
<Badge key={g.name + book.title + book.id} className="ml-0.5">
|
||||
<Badge key={g.name + book.title + book.id} className="text-[9px] px-0.5 py-0 ml-0.5">
|
||||
{g.name}
|
||||
</Badge>
|
||||
))
|
||||
@ -55,29 +56,29 @@ export default function BookList(props: Props) {
|
||||
<ul role="list" className="divide-y divide-gray-800">
|
||||
{books?.map((b) => (
|
||||
<li key={b.lcc + (b.title || '')}>
|
||||
<Link href={`/books/${b.id}`} className="flex justify-between gap-x-6 py-5">
|
||||
<div className="flex max-w-9/12 min-w-0 gap-x-4">
|
||||
<Avatar
|
||||
square
|
||||
className="size-12 flex-none bg-gray-800"
|
||||
src={
|
||||
b.isbn
|
||||
? `https://covers.openlibrary.org/b/isbn/${b.isbn}-S.jpg`
|
||||
: '/images/book-48.svg'
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-auto">
|
||||
<p className="text-sm/6 font-semibold dark:data-[state=active]:text-foreground ">
|
||||
<span>{b.title}</span>
|
||||
<small className="ml-1 italic font-thin">{b.lcc}</small>
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs/5 dark:data-[state=active]:text-foreground">
|
||||
{makeGenreBadges(b)}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/books/${b.id}`} className="grid grid-cols-9 gap-x-4 py-5">
|
||||
<Image
|
||||
alt=""
|
||||
className="w-18 h-22 flex-none bg-gray-800 col-span-2"
|
||||
width={180}
|
||||
height={220}
|
||||
src={
|
||||
b.isbn
|
||||
? `https://covers.openlibrary.org/b/isbn/${b.isbn}-M.jpg`
|
||||
: '/images/book-48.svg'
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-auto col-span-7">
|
||||
<p className="text-sm/6 dark:data-[state=active]:text-foreground ">
|
||||
<span className="block sm:inline">{b.title}</span>
|
||||
<small className="ml-1 italic font-thin">{b.lcc}</small>
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs/5 dark:data-[state=active]:text-foreground">
|
||||
{makeGenreBadges(b)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<p className="text-sm/6 text-white text-right">{makeAuthorsLabel(b)}</p>
|
||||
<div className="flex flex-col mt-2 col-span-9 sm:items-end">
|
||||
<p className="text-sm/6 text-foreground font-semibold">{makeAuthorsLabel(b)}</p>
|
||||
{!b.copies?.docs?.length ? (
|
||||
<p className="mt-1 text-xs/5 text-gray-400">No copies found</p>
|
||||
) : (
|
||||
|
29
src/components/Search/SearchBooks.tsx
Normal file
29
src/components/Search/SearchBooks.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { PaginatedDocs } from 'payload'
|
||||
import SearchBooksInlineForm from './SearchBooksInlineForm'
|
||||
import { Book } from '@/payload-types'
|
||||
import BookList from '../BookList'
|
||||
import { useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
initBrowseBooks: PaginatedDocs<Book>
|
||||
}
|
||||
const SearchBooks = (props: Props) => {
|
||||
const { initBrowseBooks } = props
|
||||
|
||||
const [bookList, setBookList] = useState<PaginatedDocs<Book> | null>(initBrowseBooks)
|
||||
|
||||
const onSearchResult = (bookList: PaginatedDocs<Book> | null) => {
|
||||
setBookList(bookList)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SearchBooksInlineForm onSearchResult={onSearchResult} />
|
||||
{bookList && <BookList books={bookList} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBooks
|
98
src/components/Search/SearchBooksInlineForm.tsx
Normal file
98
src/components/Search/SearchBooksInlineForm.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { searchBooks } from '@/serverActions/SearchBooks'
|
||||
import { PaginatedDocs } from 'payload'
|
||||
import { Book } from '@/payload-types'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().max(80),
|
||||
author: z.string().max(80),
|
||||
})
|
||||
|
||||
type Props = {
|
||||
initialTitle?: string
|
||||
initialAuthor?: string
|
||||
onSearchResult?: (results: PaginatedDocs<Book> | null) => void
|
||||
}
|
||||
const SearchBooksInlineForm = (props: Props) => {
|
||||
const { initialTitle, initialAuthor, onSearchResult } = props
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: initialTitle || '',
|
||||
author: initialAuthor || '',
|
||||
},
|
||||
})
|
||||
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (isSearching) return
|
||||
setIsSearching(true)
|
||||
|
||||
try {
|
||||
const searchResults = await searchBooks(values)
|
||||
if (searchResults && onSearchResult) onSearchResult(searchResults)
|
||||
} catch (err) {
|
||||
toast('There was an issue with that search')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-wrap items-end gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:w-auto w-full block">
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Title" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="author"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:w-auto w-full block">
|
||||
<FormLabel>Author</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Author" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className="sm:w-auto w-full block" disabled={isSearching} type="submit">
|
||||
{isSearching ? <Loader2 className="animate-spin mx-auto" /> : <span>Search</span>}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBooksInlineForm
|
@ -95,8 +95,8 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a> and{' '}
|
||||
<a href="#">Privacy Policy</a>.
|
||||
By clicking <i>Login</i> or <i>Request Access</i>, you agree to our{' '}
|
||||
<a href="/info/toc">Terms of Service</a> and <a href="/info/privacy">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
167
src/components/ui/form.tsx
Normal file
167
src/components/ui/form.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
53
src/serverActions/SearchBooks.ts
Normal file
53
src/serverActions/SearchBooks.ts
Normal file
@ -0,0 +1,53 @@
|
||||
'use server'
|
||||
|
||||
import { getPayload, PaginatedDocs } from 'payload'
|
||||
import config from '@/payload.config'
|
||||
import { Book } from '@/payload-types'
|
||||
|
||||
type SearchBooksProps = {
|
||||
author?: string,
|
||||
title?: string,
|
||||
}
|
||||
export const searchBooks = async (props: SearchBooksProps): Promise<PaginatedDocs<Book> | null> => {
|
||||
const { author, title } = props
|
||||
|
||||
const payloadConfig = await config
|
||||
const payload = await getPayload({ config: payloadConfig })
|
||||
|
||||
const andQueries = []
|
||||
|
||||
if (author) andQueries.push({
|
||||
'authors.fl': {
|
||||
like: author
|
||||
}
|
||||
})
|
||||
|
||||
if (title) andQueries.push({
|
||||
title: {
|
||||
like: title
|
||||
}
|
||||
})
|
||||
|
||||
if (!andQueries.length) return null
|
||||
|
||||
const searchResponse = (await payload.find({
|
||||
collection: 'books',
|
||||
depth: 10,
|
||||
limit: 25,
|
||||
overrideAccess: false,
|
||||
where: {
|
||||
and: andQueries
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
authors: true,
|
||||
publication: true,
|
||||
lcc: true,
|
||||
genre: true,
|
||||
isbn: true,
|
||||
copies: true,
|
||||
},
|
||||
})) as PaginatedDocs<Book>
|
||||
|
||||
return searchResponse
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user