From 6d32990cb6689f5ef6a2acebab1e1ac3afacd854 Mon Sep 17 00:00:00 2001 From: Yehoshua Sandler Date: Mon, 28 Apr 2025 09:09:13 -0500 Subject: [PATCH] feat: search books by author and title --- package-lock.json | 65 ++++++- package.json | 6 +- src/app/(frontend)/login/page.tsx | 10 +- src/app/(frontend)/page.tsx | 40 +++-- src/app/layout.tsx | 2 + src/components/BookList/index.tsx | 47 ++--- src/components/Search/SearchBooks.tsx | 29 +++ .../Search/SearchBooksInlineForm.tsx | 98 ++++++++++ src/components/login-form.tsx | 4 +- src/components/ui/form.tsx | 167 ++++++++++++++++++ src/components/ui/sonner.tsx | 25 +++ src/serverActions/SearchBooks.ts | 53 ++++++ 12 files changed, 497 insertions(+), 49 deletions(-) create mode 100644 src/components/Search/SearchBooks.tsx create mode 100644 src/components/Search/SearchBooksInlineForm.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/serverActions/SearchBooks.ts diff --git a/package-lock.json b/package-lock.json index 80006df..39c6bec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4149a94..9214cec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(frontend)/login/page.tsx b/src/app/(frontend)/login/page.tsx index 89aa6a5..134e21b 100644 --- a/src/app/(frontend)/login/page.tsx +++ b/src/app/(frontend)/login/page.tsx @@ -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 (
- +
{ />
- Developed by - Beitzah.ts + Developed with 💜 + Beitzah.tech
diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx index 8c0f0c6..8c1ad17 100644 --- a/src/app/(frontend)/page.tsx +++ b/src/app/(frontend)/page.tsx @@ -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 (
-
+
Welcome  - {user && {user.firstName}} + + {user ? {user.firstName} : In} +

@@ -94,21 +98,27 @@ export default async function HomePage() {

- - - Your Feed - Browse - Search - + {user ? ( + + + Your Feed + Search + Your Repos + - {user && } + {user && } - - {initBrowseBooks && } - + + + - Search - + Your Repos + + ) : ( +
+ +
+ )}
) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fb015b7..c49729e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,11 +5,13 @@ type LayoutProps = { } import './globals.css' +import { Toaster } from '@/components/ui/sonner' const Layout = ({ children }: LayoutProps) => { return ( {children} + ) } diff --git a/src/components/BookList/index.tsx b/src/components/BookList/index.tsx index 24a4003..a5559c0 100644 --- a/src/components/BookList/index.tsx +++ b/src/components/BookList/index.tsx @@ -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 @@ -37,7 +38,7 @@ const makeAuthorsLabel = (book: Book) => { const makeGenreBadges = (book: Book) => { return (book.genre as Genre[])?.map((g) => ( - + {g.name} )) @@ -55,29 +56,29 @@ export default function BookList(props: Props) {
    {books?.map((b) => (
  • - -
    - -
    -

    - {b.title} - {b.lcc} -

    -

    - {makeGenreBadges(b)} -

    -
    + + +
    +

    + {b.title} + {b.lcc} +

    +

    + {makeGenreBadges(b)} +

    -
    -

    {makeAuthorsLabel(b)}

    +
    +

    {makeAuthorsLabel(b)}

    {!b.copies?.docs?.length ? (

    No copies found

    ) : ( diff --git a/src/components/Search/SearchBooks.tsx b/src/components/Search/SearchBooks.tsx new file mode 100644 index 0000000..b75b11a --- /dev/null +++ b/src/components/Search/SearchBooks.tsx @@ -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 +} +const SearchBooks = (props: Props) => { + const { initBrowseBooks } = props + + const [bookList, setBookList] = useState | null>(initBrowseBooks) + + const onSearchResult = (bookList: PaginatedDocs | null) => { + setBookList(bookList) + } + + return ( +
    + + {bookList && } +
    + ) +} + +export default SearchBooks diff --git a/src/components/Search/SearchBooksInlineForm.tsx b/src/components/Search/SearchBooksInlineForm.tsx new file mode 100644 index 0000000..6feec76 --- /dev/null +++ b/src/components/Search/SearchBooksInlineForm.tsx @@ -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 | null) => void +} +const SearchBooksInlineForm = (props: Props) => { + const { initialTitle, initialAuthor, onSearchResult } = props + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + title: initialTitle || '', + author: initialAuthor || '', + }, + }) + + const [isSearching, setIsSearching] = useState(false) + + const onSubmit = async (values: z.infer) => { + 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 ( +
    + + ( + + Title + + + + + + )} + /> + ( + + Author + + + + + + )} + /> + + + + ) +} + +export default SearchBooksInlineForm diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index 6704c08..b954298 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -95,8 +95,8 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
    - By clicking continue, you agree to our Terms of Service and{' '} - Privacy Policy. + By clicking Login or Request Access, you agree to our{' '} + Terms of Service and Privacy Policy.
    ) diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..524b986 --- /dev/null +++ b/src/components/ui/form.tsx @@ -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 = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +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 ") + } + + 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( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
    + + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +