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 (
+
+ )
+}
+
+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 (
+
+
+ )
+}
+
+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'>)
)
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 (
+
+ )
+}
+
+function FormControl({ ...props }: React.ComponentProps) {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : props.children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+}
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..957524e
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -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 (
+
+ )
+}
+
+export { Toaster }
diff --git a/src/serverActions/SearchBooks.ts b/src/serverActions/SearchBooks.ts
new file mode 100644
index 0000000..a1efe27
--- /dev/null
+++ b/src/serverActions/SearchBooks.ts
@@ -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 | 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
+
+ return searchResponse
+}