Skip to content

Commit 682ee9c

Browse files
committed
feat: watchlist
1 parent 0b3bc6a commit 682ee9c

File tree

11 files changed

+339
-20
lines changed

11 files changed

+339
-20
lines changed

app/(protected)/admin/books/[id]/page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ export default async function BookDetailsPage({
5151
{/* Book Cover */}
5252
<div className="lg:col-span-1 grid place-items-center gap-4">
5353
<ThreeDBook book={bookRes.data} />
54-
<Button className="w-full" disabled={true}>
54+
<Button
55+
className="w-full"
56+
disabled={!bookRes.data.stats?.is_available}
57+
>
5558
{true ? (
5659
<>
5760
<BookDown className="mr-2 h-4 w-4" />
@@ -61,9 +64,6 @@ export default async function BookDetailsPage({
6164
'Currently Borrowed'
6265
)}
6366
</Button>
64-
<Button variant="outline" className="w-full bg-transparent">
65-
Add to Wishlist
66-
</Button>
6767
</div>
6868

6969
{/* Book Information */}

app/(protected)/books/[id]/page.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,24 @@ import {
88
} from '@/components/ui/breadcrumb'
99
import { getBook } from '@/lib/api/book'
1010
import { BookDown } from 'lucide-react'
11+
12+
import BtnWatchlist from '@/components/books/BtnWatchlist'
1113
import { Button } from '@/components/ui/button'
1214
import { DetailBook } from '@/components/books/DetailBook'
15+
import { IsLoggedIn } from '@/lib/firebase/firebase'
1316

1417
export default async function BookDetailsPage({
1518
params,
1619
}: {
1720
params: Promise<{ id: string }>
1821
}) {
22+
const claims = await IsLoggedIn()
23+
1924
const { id } = await params
2025

21-
const [bookRes] = await Promise.all([getBook({ id, include_stats: 'true' })])
26+
const [bookRes] = await Promise.all([
27+
getBook({ id, include_stats: 'true', user_id: claims?.librarease.id }),
28+
])
2229

2330
if ('error' in bookRes) {
2431
console.log({ libRes: bookRes })
@@ -45,19 +52,16 @@ export default async function BookDetailsPage({
4552
</Breadcrumb>
4653

4754
<DetailBook book={bookRes.data}>
48-
<Button className="w-full" disabled={true}>
49-
{true ? (
50-
<>
51-
<BookDown className="mr-2 h-4 w-4" />
52-
Borrow Book
53-
</>
54-
) : (
55-
'Currently Borrowed'
56-
)}
57-
</Button>
58-
<Button variant="outline" className="w-full bg-transparent">
59-
Add to Wishlist
55+
<Button className="w-full" disabled={!bookRes.data.stats?.is_available}>
56+
<>
57+
<BookDown className="mr-2 h-4 w-4" />
58+
Borrow Book
59+
</>
6060
</Button>
61+
<BtnWatchlist
62+
bookId={bookRes.data.id}
63+
isWatched={!!bookRes.data.watchlists?.[0]}
64+
/>
6165
</DetailBook>
6266

6367
{/* <div className="place-self-center text-center pt-4 border-t">

app/(protected)/books/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ import { getListBooks } from '@/lib/api/book'
1717
import Link from 'next/link'
1818
import type { Metadata } from 'next'
1919
import { SITE_NAME } from '@/lib/consts'
20-
import { Search } from 'lucide-react'
20+
import { BellRing, Search } from 'lucide-react'
2121
import { DebouncedInput } from '@/components/common/DebouncedInput'
2222
import { Badge } from '@/components/ui/badge'
2323
import { ListBook } from '@/components/books/ListBook'
2424
import { Verify } from '@/lib/firebase/firebase'
25+
import { Button } from '@/components/ui/button'
2526

2627
export const metadata: Metadata = {
2728
title: `Books · ${SITE_NAME}`,
@@ -88,6 +89,14 @@ export default async function UserBooks({
8889
</BreadcrumbItem>
8990
</BreadcrumbList>
9091
</Breadcrumb>
92+
<Button variant="secondary" asChild>
93+
<Link href="/books/watchlist">
94+
<>
95+
<BellRing className="mr-2 h-4 w-4" />
96+
My Watchlist
97+
</>
98+
</Link>
99+
</Button>
91100
</div>
92101
</nav>
93102

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {
2+
Breadcrumb,
3+
BreadcrumbItem,
4+
BreadcrumbLink,
5+
BreadcrumbList,
6+
BreadcrumbPage,
7+
BreadcrumbSeparator,
8+
} from '@/components/ui/breadcrumb'
9+
import {
10+
Pagination,
11+
PaginationContent,
12+
PaginationItem,
13+
PaginationNext,
14+
PaginationPrevious,
15+
} from '@/components/ui/pagination'
16+
import { getListBooks } from '@/lib/api/book'
17+
import Link from 'next/link'
18+
import type { Metadata } from 'next'
19+
import { SITE_NAME } from '@/lib/consts'
20+
import { Search } from 'lucide-react'
21+
import { DebouncedInput } from '@/components/common/DebouncedInput'
22+
import { Badge } from '@/components/ui/badge'
23+
import { ListBook } from '@/components/books/ListBook'
24+
import { Verify } from '@/lib/firebase/firebase'
25+
import { getListWatchlist } from '@/lib/api/watchlist'
26+
27+
export const metadata: Metadata = {
28+
title: `Watchlist Books · ${SITE_NAME}`,
29+
}
30+
31+
export default async function UserBooks({
32+
searchParams,
33+
}: {
34+
searchParams: Promise<{
35+
skip?: number
36+
limit?: number
37+
library_id?: string
38+
title?: string
39+
}>
40+
}) {
41+
const sp = await searchParams
42+
const skip = Number(sp?.skip ?? 0)
43+
const limit = Number(sp?.limit ?? 20)
44+
const library_id = sp?.library_id
45+
46+
const query = {
47+
sort_by: 'created_at',
48+
sort_in: 'desc',
49+
limit: limit,
50+
skip: skip,
51+
title: sp?.title,
52+
include_stats: 'true',
53+
...(library_id ? { library_id } : {}),
54+
} as const
55+
56+
const headers = await Verify({ from: '/books' })
57+
58+
const res = await getListWatchlist(query, { headers })
59+
60+
if ('error' in res) {
61+
console.log(res)
62+
return <div>{JSON.stringify(res.message)}</div>
63+
}
64+
65+
const prevSkip = skip - limit > 0 ? skip - limit : 0
66+
67+
const nextURL = `/books?skip=${skip + limit}&limit=${limit}` as const
68+
const prevURL = `/books?skip=${prevSkip}&limit=${limit}` as const
69+
70+
return (
71+
<div className="space-y-4">
72+
<nav className="backdrop-blur-sm sticky top-0 z-10">
73+
<h1 className="text-2xl font-semibold">Books</h1>
74+
<div className="flex justify-between items-center">
75+
<Breadcrumb>
76+
<BreadcrumbList>
77+
<BreadcrumbItem>
78+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
79+
</BreadcrumbItem>
80+
<BreadcrumbSeparator />
81+
82+
<BreadcrumbItem>
83+
<BreadcrumbPage>
84+
Books{' '}
85+
<Badge className="ml-4" variant="outline">
86+
{res.meta.total}
87+
</Badge>
88+
</BreadcrumbPage>
89+
</BreadcrumbItem>
90+
</BreadcrumbList>
91+
</Breadcrumb>
92+
</div>
93+
</nav>
94+
95+
<div className="relative flex-1">
96+
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
97+
98+
<DebouncedInput
99+
name="title"
100+
placeholder="Search by title"
101+
className="pl-8 max-w-md"
102+
/>
103+
</div>
104+
105+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
106+
{res.data.map((book) => (
107+
<Link key={book.id} href={`/books/${book.id}`} passHref>
108+
<ListBook book={book} />
109+
</Link>
110+
))}
111+
</div>
112+
<Pagination>
113+
<PaginationContent>
114+
{res.meta.skip > 0 && (
115+
<PaginationItem>
116+
<PaginationPrevious href={prevURL} />
117+
</PaginationItem>
118+
)}
119+
{res.meta.limit <= res.data.length && (
120+
<PaginationItem>
121+
<PaginationNext href={nextURL} />
122+
</PaginationItem>
123+
)}
124+
</PaginationContent>
125+
</Pagination>
126+
</div>
127+
)
128+
}

components/books/BtnWatchlist.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
import { useTransition } from 'react'
3+
import { BellMinus, BellRing, Loader } from 'lucide-react'
4+
import { Button } from '@/components/ui/button'
5+
import {
6+
addToWatchlistAction,
7+
removeFromWatchlistAction,
8+
} from '@/lib/actions/watchlist'
9+
10+
export default function BtnWatchlist({
11+
bookId,
12+
isWatched,
13+
}: {
14+
bookId: string
15+
isWatched: boolean
16+
}) {
17+
const [pending, startTransition] = useTransition()
18+
19+
const handleAdd = () => startTransition(() => addToWatchlistAction(bookId))
20+
const handleRemove = () =>
21+
startTransition(() => removeFromWatchlistAction(bookId))
22+
23+
if (isWatched) {
24+
return (
25+
<Button
26+
variant="outline"
27+
className="w-full bg-transparent"
28+
onClick={handleRemove}
29+
disabled={pending}
30+
>
31+
<>
32+
{pending ? (
33+
<Loader className="mr-2 h-4 w-4 animate-spin" />
34+
) : (
35+
<BellMinus className="mr-2 h-4 w-4 text-destructive" />
36+
)}
37+
Remove from Watchlist
38+
</>
39+
</Button>
40+
)
41+
}
42+
return (
43+
<Button
44+
variant="outline"
45+
className="w-full bg-transparent"
46+
onClick={handleAdd}
47+
disabled={pending}
48+
>
49+
<>
50+
{pending ? (
51+
<Loader className="mr-2 h-4 w-4 animate-spin" />
52+
) : (
53+
<BellRing className="mr-2 h-4 w-4" />
54+
)}
55+
Add to Watchlist
56+
</>
57+
</Button>
58+
)
59+
}

components/nav-user.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ export function NavUser({
3131
}) {
3232
useEffect(() => {
3333
function onMessage(data: Notification) {
34-
toast(data.message)
34+
toast.info(data.title, {
35+
description: data.message,
36+
})
3537
}
3638
function onError(event: Event) {
3739
console.warn('Error in notification stream:', event)

lib/actions/watchlist.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use server'
2+
3+
import { revalidatePath } from 'next/cache'
4+
import { addToWatchlist, removeFromWatchlist } from '../api/watchlist'
5+
import { Verify } from '../firebase/firebase'
6+
7+
export async function addToWatchlistAction(bookId: string) {
8+
const headers = await Verify({ from: `/books/${bookId}` })
9+
10+
try {
11+
const res = await addToWatchlist(bookId, { headers })
12+
revalidatePath(`/books/${bookId}`)
13+
return res
14+
} catch (e) {
15+
if (e instanceof Object && 'error' in e) {
16+
return { error: e.error as string }
17+
}
18+
return { error: 'failed to add to watchlist' }
19+
}
20+
}
21+
22+
export async function removeFromWatchlistAction(bookId: string) {
23+
const headers = await Verify({ from: `/books/${bookId}` })
24+
25+
try {
26+
const res = await removeFromWatchlist(bookId, { headers })
27+
revalidatePath(`/books/${bookId}`)
28+
return res
29+
} catch (e) {
30+
if (e instanceof Object && 'error' in e) {
31+
return { error: e.error as string }
32+
}
33+
return { error: 'failed to remove from watchlist' }
34+
}
35+
}

lib/api/book.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,18 @@ export const getListBooks = async (
2525
return response.json()
2626
}
2727

28-
type GetBookQuery = Pick<Book, 'id'> & { include_stats?: 'true' }
28+
type GetBookQuery = Pick<Book, 'id'> & {
29+
include_stats?: 'true'
30+
user_id?: string
31+
}
2932
type GetBookResponse = Promise<ResSingle<BookDetail>>
3033
export const getBook = async (query: GetBookQuery): GetBookResponse => {
3134
const url = new URL(`${BOOKS_URL}/${query.id}`)
35+
Object.entries(query).forEach(([key, value]) => {
36+
if (key !== 'id' && value) {
37+
url.searchParams.append(key, String(value))
38+
}
39+
})
3240
const response = await fetch(url.toString())
3341
return response.json()
3442
}

0 commit comments

Comments
 (0)