Skip to content

Commit d68bcc4

Browse files
committed
feat: review list page
1 parent ccda15a commit d68bcc4

File tree

7 files changed

+346
-21
lines changed

7 files changed

+346
-21
lines changed

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,11 @@ export default async function BookDetailsPage({
161161
))}
162162
</div>
163163
<div className="mt-6 pt-6 border-t">
164-
{/* <Link href="/reviews"> */}
165-
<Button variant="outline" className="w-full bg-transparent">
166-
View All Reviews ({reviewsRes.meta.total})
167-
</Button>
168-
{/* </Link> */}
164+
<Link href="/admin/reviews">
165+
<Button variant="outline" className="w-full bg-transparent">
166+
View All Reviews ({reviewsRes.meta.total})
167+
</Button>
168+
</Link>
169169
</div>
170170
</CardContent>
171171
</Card>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {
2+
Breadcrumb,
3+
BreadcrumbItem,
4+
BreadcrumbLink,
5+
BreadcrumbList,
6+
BreadcrumbSeparator,
7+
} from '@/components/ui/breadcrumb'
8+
9+
export default async function ReviewsLayout({
10+
children,
11+
}: Readonly<{
12+
children: React.ReactNode
13+
}>) {
14+
return (
15+
<>
16+
<nav className="backdrop-blur-sm sticky top-0 z-10 mb-4">
17+
<h1 className="text-2xl font-semibold">Reviews</h1>
18+
<div className="flex justify-between items-center">
19+
<Breadcrumb>
20+
<BreadcrumbList>
21+
<BreadcrumbItem>
22+
<BreadcrumbLink href="/">Home</BreadcrumbLink>
23+
</BreadcrumbItem>
24+
<BreadcrumbSeparator />
25+
<BreadcrumbItem>Reviews</BreadcrumbItem>
26+
</BreadcrumbList>
27+
</Breadcrumb>
28+
</div>
29+
</nav>
30+
{children}
31+
</>
32+
)
33+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { getListReviews } from '@/lib/api/review'
2+
import { Verify } from '@/lib/firebase/firebase'
3+
import { Calendar, Star } from 'lucide-react'
4+
import { Card, CardContent } from '@/components/ui/card'
5+
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
6+
import { formatDate } from '@/lib/utils'
7+
import { DateTime } from '@/components/common/DateTime'
8+
import {
9+
Pagination,
10+
PaginationContent,
11+
PaginationItem,
12+
PaginationNext,
13+
PaginationPrevious,
14+
} from '@/components/ui/pagination'
15+
import { Route } from 'next'
16+
import { cookies } from 'next/headers'
17+
import Image from 'next/image'
18+
import Link from 'next/link'
19+
import clsx from 'clsx'
20+
21+
export default async function ReviewsPage({
22+
searchParams,
23+
}: {
24+
searchParams: Promise<{
25+
skip?: number
26+
limit?: number
27+
rating?: number
28+
comment?: string
29+
}>
30+
}) {
31+
const { rating, comment, ...sp } = await searchParams
32+
const skip = Number(sp?.skip ?? 0)
33+
const limit = Number(sp?.limit ?? 20)
34+
35+
const headers = await Verify({
36+
from: `/admin/reviews`,
37+
})
38+
39+
const cookieStore = await cookies()
40+
const sessionName = process.env.LIBRARY_COOKIE_NAME as string
41+
const activeLibraryID = cookieStore.get(sessionName)?.value
42+
43+
const res = await getListReviews(
44+
{
45+
library_id: activeLibraryID,
46+
skip,
47+
rating,
48+
comment,
49+
limit,
50+
},
51+
{ headers }
52+
)
53+
54+
if ('error' in res) {
55+
return <div>{JSON.stringify(res.message)}</div>
56+
}
57+
58+
const prevSkip = skip - limit > 0 ? skip - limit : 0
59+
60+
const nextURL = `/admin/reviews?skip=${skip + limit}&limit=${limit}` as const
61+
const prevURL = `/admin/reviews?skip=${prevSkip}&limit=${limit}` as const
62+
63+
return (
64+
<div className="space-y-4">
65+
{res.data.map((review) => (
66+
<Card key={review.id} className="hover:shadow-md transition-shadow">
67+
<CardContent>
68+
<Link href={`/admin/books/${review.book.id}`} className="shrink-0">
69+
<div className="flex gap-4">
70+
{/* 3D Book Effect */}
71+
<div className="shrink-0">
72+
<div className="flex">
73+
<div className="bg-accent transform-[perspective(400px)_rotateY(314deg)] -mr-1 w-4">
74+
<span className="inline-block text-nowrap text-[0.5rem] font-bold text-accent-foreground/50 transform-[rotate(90deg)_translateY(-16px)] origin-top-left"></span>
75+
</div>
76+
<Image
77+
src={review.book.cover ?? '/book-placeholder.svg'}
78+
alt={review.book.title + "'s cover"}
79+
width={96}
80+
height={144}
81+
className={clsx(
82+
'shadow-xl rounded-r-md md:w-24 md:h-36 object-cover',
83+
'transform-[perspective(800px)_rotateY(14deg)]'
84+
)}
85+
priority
86+
/>
87+
</div>
88+
</div>
89+
<div className="space-y-4">
90+
<div className="flex flex-col md:flex-row md:justify-between">
91+
<div>
92+
<h4 className="font-medium">{review.book.title}</h4>
93+
<div>{review.book.code}</div>
94+
</div>
95+
96+
<div className="flex items-center gap-1">
97+
{[1, 2, 3, 4, 5].map((star) => (
98+
<Star
99+
key={star}
100+
className={`h-5 w-5 ${
101+
star <= review.rating
102+
? 'fill-(--color-vibrant,var(--color-yellow-400)) text-(--color-vibrant,var(--color-yellow-400))'
103+
: 'text-gray-300'
104+
}`}
105+
/>
106+
))}
107+
</div>
108+
</div>
109+
110+
{/* Review text */}
111+
<div>
112+
<p className="text-sm leading-relaxed mb-4 text-foreground">
113+
{review.comment}
114+
</p>
115+
116+
<div className="flex items-center gap-2">
117+
<Avatar className="h-8 w-8">
118+
<AvatarFallback className="text-xs bg-primary/10">
119+
{review.user.name.slice(0, 2)}
120+
</AvatarFallback>
121+
</Avatar>
122+
<div>{review.user.name}</div>
123+
<div className="ml-auto flex gap-1">
124+
<Calendar className="h-3 w-3" />
125+
<DateTime
126+
dateTime={review.created_at}
127+
className="text-xs text-muted-foreground"
128+
>
129+
{formatDate(review.created_at)}
130+
</DateTime>
131+
</div>
132+
</div>
133+
</div>
134+
</div>
135+
</div>
136+
</Link>
137+
</CardContent>
138+
</Card>
139+
))}
140+
141+
<Pagination>
142+
<PaginationContent>
143+
{res.meta.skip > 0 && (
144+
<PaginationItem>
145+
<PaginationPrevious href={prevURL as Route} />
146+
</PaginationItem>
147+
)}
148+
{res.meta.limit <= res.data.length && (
149+
<PaginationItem>
150+
<PaginationNext href={nextURL as Route} />
151+
</PaginationItem>
152+
)}
153+
</PaginationContent>
154+
</Pagination>
155+
</div>
156+
)
157+
}

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
1010
import { IsLoggedIn, Verify } from '@/lib/firebase/firebase'
1111
import { Review } from '@/components/reviews/Review'
1212
import { DataRecentBorrows } from '@/components/books/DataRecentBorrows'
13+
import { DateTime } from '@/components/common/DateTime'
1314

1415
export default async function BookDetailsPage({
1516
params,
@@ -139,15 +140,17 @@ export default async function BookDetailsPage({
139140
<div className="flex items-center gap-2">
140141
<Avatar className="h-8 w-8">
141142
<AvatarFallback className="text-xs bg-primary/10">
142-
{review.user?.name?.slice(0, 2)}
143+
{review.user.name.slice(0, 2)}
143144
</AvatarFallback>
144145
</Avatar>
145-
<div>
146-
{review.user?.name}
147-
<div className="flex items-center gap-1 text-xs text-muted-foreground">
148-
<Calendar className="h-3 w-3" />
146+
<div className="flex flex-col">
147+
<h4 className="font-medium">{review.user.name}</h4>
148+
<DateTime
149+
dateTime={review.created_at}
150+
className="text-xs text-muted-foreground"
151+
>
149152
{formatDate(review.created_at)}
150-
</div>
153+
</DateTime>
151154
</div>
152155
</div>
153156
<div className="flex items-center gap-1">
@@ -170,11 +173,18 @@ export default async function BookDetailsPage({
170173
))}
171174
</div>
172175
<div className="mt-6 pt-6 border-t">
173-
{/* <Link href="/reviews"> */}
174-
<Button variant="outline" className="w-full bg-transparent">
175-
View All Reviews ({reviewsRes.meta.total})
176-
</Button>
177-
{/* </Link> */}
176+
<Link
177+
href={`/books/${id}/reviews`}
178+
aria-disabled={reviewsRes.meta.total === 0}
179+
>
180+
<Button
181+
variant="outline"
182+
className="w-full bg-transparent"
183+
disabled={reviewsRes.meta.total === 0}
184+
>
185+
View All Reviews ({reviewsRes.meta.total})
186+
</Button>
187+
</Link>
178188
</div>
179189
</CardContent>
180190
</Card>
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { getListReviews } from '@/lib/api/review'
2+
import { Verify } from '@/lib/firebase/firebase'
3+
import { Star } from 'lucide-react'
4+
import { Card, CardContent } from '@/components/ui/card'
5+
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
6+
import { formatDate } from '@/lib/utils'
7+
import { DateTime } from '@/components/common/DateTime'
8+
import {
9+
Pagination,
10+
PaginationContent,
11+
PaginationItem,
12+
PaginationNext,
13+
PaginationPrevious,
14+
} from '@/components/ui/pagination'
15+
import { Route } from 'next'
16+
17+
export default async function BookReviewsPage({
18+
params,
19+
searchParams,
20+
}: {
21+
params: Promise<{ id: string }>
22+
searchParams: Promise<{
23+
skip?: number
24+
limit?: number
25+
rating?: number
26+
comment?: string
27+
}>
28+
}) {
29+
const { id } = await params
30+
const { rating, comment, ...sp } = await searchParams
31+
const skip = Number(sp?.skip ?? 0)
32+
const limit = Number(sp?.limit ?? 20)
33+
34+
const headers = await Verify({
35+
from: `/books/${id}/reviews`,
36+
})
37+
38+
const res = await getListReviews(
39+
{
40+
book_id: id,
41+
skip,
42+
rating,
43+
comment,
44+
limit,
45+
},
46+
{ headers }
47+
)
48+
49+
if ('error' in res) {
50+
return <div>{JSON.stringify(res.message)}</div>
51+
}
52+
53+
const prevSkip = skip - limit > 0 ? skip - limit : 0
54+
55+
const nextURL =
56+
`/books/${id}/reviews?skip=${skip + limit}&limit=${limit}` as const
57+
const prevURL =
58+
`/books/${id}/reviews?skip=${prevSkip}&limit=${limit}` as const
59+
60+
return (
61+
<div className="space-y-4">
62+
{res.data.map((review) => (
63+
<Card key={review.id} className="hover:shadow-md transition-shadow">
64+
<CardContent>
65+
<div className="space-y-4">
66+
{/* Header with user and rating */}
67+
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-2">
68+
<div className="space-y-2">
69+
<div className="flex items-center gap-2">
70+
<Avatar className="h-8 w-8">
71+
<AvatarFallback className="text-xs bg-primary/10">
72+
{review.user.name.slice(0, 2)}
73+
</AvatarFallback>
74+
</Avatar>
75+
<div className="flex flex-col">
76+
<h4 className="font-medium">{review.user.name}</h4>
77+
<DateTime
78+
dateTime={review.created_at}
79+
className="text-xs text-muted-foreground"
80+
>
81+
{formatDate(review.created_at)}
82+
</DateTime>
83+
</div>
84+
</div>
85+
</div>
86+
<div className="flex items-center gap-1">
87+
{[1, 2, 3, 4, 5].map((star) => (
88+
<Star
89+
key={star}
90+
className={`h-5 w-5 ${
91+
star <= review.rating
92+
? 'fill-(--color-vibrant,var(--color-yellow-400)) text-(--color-vibrant,var(--color-yellow-400))'
93+
: 'text-gray-300'
94+
}`}
95+
/>
96+
))}
97+
</div>
98+
</div>
99+
100+
{/* Review text */}
101+
<p className="text-sm leading-relaxed mb-4 text-foreground">
102+
{review.comment}
103+
</p>
104+
</div>
105+
</CardContent>
106+
</Card>
107+
))}
108+
109+
<Pagination>
110+
<PaginationContent>
111+
{res.meta.skip > 0 && (
112+
<PaginationItem>
113+
<PaginationPrevious href={prevURL as Route} />
114+
</PaginationItem>
115+
)}
116+
{res.meta.limit <= res.data.length && (
117+
<PaginationItem>
118+
<PaginationNext href={nextURL as Route} />
119+
</PaginationItem>
120+
)}
121+
</PaginationContent>
122+
</Pagination>
123+
</div>
124+
)
125+
}

0 commit comments

Comments
 (0)