Skip to content

Commit 7b76343

Browse files
committed
feat: notification page
1 parent 6fd90f9 commit 7b76343

File tree

8 files changed

+182
-30
lines changed

8 files changed

+182
-30
lines changed

app/(protected)/notifications/page.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ import {
1616
} from '@/components/ui/pagination'
1717

1818
import { Verify } from '@/lib/firebase/firebase'
19-
import { CheckCircle } from 'lucide-react'
2019
import type { Metadata } from 'next'
2120
import { SITE_NAME } from '@/lib/consts'
2221
import { getListNotifications } from '@/lib/api/notification'
2322
import { readAllNotificationsAction } from '@/lib/actions/notification'
23+
import { TabLink } from '@/components/borrows/TabLink'
24+
import { CheckCircle } from 'lucide-react'
25+
import { Notification } from '@/components/notifications/Notification'
2426

2527
export const metadata: Metadata = {
2628
title: `Notifications · ${SITE_NAME}`,
@@ -32,6 +34,7 @@ export default async function Notifications({
3234
searchParams: Promise<{
3335
skip?: number
3436
limit?: number
37+
is_unread?: 'true'
3538
}>
3639
}) {
3740
const sp = await searchParams
@@ -46,6 +49,7 @@ export default async function Notifications({
4649
{
4750
limit: limit,
4851
skip: skip,
52+
is_unread: sp?.is_unread,
4953
},
5054
{
5155
headers,
@@ -88,21 +92,20 @@ export default async function Notifications({
8892
</Button>
8993
</div>
9094
</nav>
91-
<ul className="space-y-2">
92-
{res.data.map((notification) => (
93-
<li key={notification.id} className="p-4 border rounded-md">
94-
<div className="flex items-center justify-between">
95-
<div>
96-
<h2 className="text-lg font-semibold">{notification.title}</h2>
97-
<p className="text-sm text-gray-500">{notification.message}</p>
98-
</div>
99-
<span className="text-xs text-gray-400">
100-
{new Date(notification.created_at).toLocaleDateString()}
101-
</span>
102-
</div>
103-
</li>
95+
<TabLink
96+
tabs={[
97+
{ name: 'Unread', href: '/notifications?is_unread=true' },
98+
{ name: 'All', href: '/notifications' },
99+
]}
100+
activeHref={`/notifications${sp?.is_unread ? `?is_unread=${sp.is_unread}` : ''}`}
101+
/>
102+
103+
<div className="space-y-2">
104+
{res.data.map((noti) => (
105+
<Notification key={noti.id} noti={noti} />
104106
))}
105-
</ul>
107+
</div>
108+
106109
<Pagination>
107110
<PaginationContent>
108111
{res.meta.skip > 0 && (

components/borrows/ListCardBorrow.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Badge } from '@/components/ui/badge'
22
import { Borrow } from '@/lib/types/borrow'
3-
import { isBorrowDue, formatDate, getBorrowStatus } from '@/lib/utils'
3+
import { formatDate, getBorrowStatus, cn, isBorrowDue } from '@/lib/utils'
44
import {
55
Calendar,
66
CalendarClock,
@@ -17,21 +17,19 @@ import {
1717
CardHeader,
1818
CardTitle,
1919
} from '@/components/ui/card'
20-
import clsx from 'clsx'
2120
import Link from 'next/link'
2221
import { Route } from 'next'
2322

2423
export const ListCardBorrow: React.FC<
2524
React.PropsWithChildren<{ borrow: Borrow; idx: number }>
2625
> = ({ borrow, idx, children }) => {
26+
const status = getBorrowStatus(borrow)
2727
const isDue = isBorrowDue(borrow)
2828

2929
return (
3030
<Card
3131
key={borrow.id}
32-
className={clsx('relative', {
33-
'bg-destructive/5': isDue,
34-
})}
32+
className={cn('relative', status === 'lost' && 'bg-destructive/5')}
3533
>
3634
<CardHeader>
3735
<Link
@@ -84,7 +82,7 @@ export const ListCardBorrow: React.FC<
8482
) : (
8583
<CalendarClock className="size-4 text-muted-foreground" />
8684
)}
87-
<span className={`${isDue ? 'text-destructive' : ''}`}>
85+
<span className={cn(isDue && 'text-destructive')}>
8886
Due: {formatDate(borrow.due_at)}
8987
</span>
9088
</div>

components/button-toggle-theme.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import * as React from 'react'
4-
import { Moon, Sun } from 'lucide-react'
4+
import { Monitor, Moon, Sun } from 'lucide-react'
55
import { useTheme } from 'next-themes'
66

77
import { Button } from '@/components/ui/button'
@@ -19,8 +19,8 @@ export function ModeToggle() {
1919
<DropdownMenu>
2020
<DropdownMenuTrigger asChild>
2121
<Button variant="outline" size="icon">
22-
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
23-
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
22+
<Sun className="scale-100 dark:scale-0 dark:-rotate-90" />
23+
<Moon className="absolute scale-0 dark:scale-100 dark:rotate-0" />
2424
<span className="sr-only">Toggle theme</span>
2525
</Button>
2626
</DropdownMenuTrigger>
@@ -38,3 +38,29 @@ export function ModeToggle() {
3838
</DropdownMenu>
3939
)
4040
}
41+
42+
export function ButtonToggleTheme() {
43+
const { setTheme } = useTheme()
44+
45+
return (
46+
<fieldset className="flex items-center gap-2 justify-center">
47+
<legend className="sr-only">Toggle theme</legend>
48+
<Button
49+
variant="outline"
50+
size="icon"
51+
onClick={() => setTheme('light')}
52+
aria-label="Set light theme"
53+
>
54+
<Sun className="text-primary dark:text-foreground" />
55+
</Button>
56+
<Button
57+
variant="outline"
58+
size="icon"
59+
onClick={() => setTheme('dark')}
60+
aria-label="Set dark theme"
61+
>
62+
<Moon className="text-foreground dark:text-primary" />
63+
</Button>
64+
</fieldset>
65+
)
66+
}

components/nav-user.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { User } from '@/lib/types/user'
2121
import { Badge } from './ui/badge'
2222
import Link from 'next/link'
2323
import { toast } from 'sonner'
24+
import { ButtonToggleTheme } from './button-toggle-theme'
2425

2526
export function NavUser({
2627
user,
@@ -162,6 +163,8 @@ export function NavUser({
162163
<LogOutIcon />
163164
Log out
164165
</DropdownMenuItem>
166+
<DropdownMenuSeparator />
167+
<ButtonToggleTheme />
165168
</DropdownMenuContent>
166169
</DropdownMenu>
167170
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Notification as TNotification } from '@/lib/types/notification'
2+
import { cn, formatDate } from '@/lib/utils'
3+
import { Badge } from '@/components/ui/badge'
4+
import { Bell, BookHeart, BookUser, CreditCard } from 'lucide-react'
5+
import { NotificationAction } from './NotificationAction'
6+
import Link from 'next/link'
7+
import { Route } from 'next'
8+
9+
export const Notification: React.FC<{ noti: TNotification }> = ({ noti }) => {
10+
return (
11+
<div
12+
key={noti.id}
13+
className={cn(
14+
'flex gap-4 p-4 rounded-lg border',
15+
!noti.read_at && 'bg-secondary/50 border-secondary'
16+
)}
17+
>
18+
<div className="mt-1">{getIcon(noti.reference_type)}</div>
19+
<div className="flex-1 min-w-0">
20+
<div className="flex justify-between items-start">
21+
<h3 className={cn('font-medium', !noti.read_at && 'font-semibold')}>
22+
{noti.title}
23+
</h3>
24+
<span className="text-xs text-muted-foreground whitespace-nowrap ml-2">
25+
{formatDate(noti.created_at, {})}
26+
</span>
27+
</div>
28+
<p className="text-sm text-muted-foreground">{noti.message}</p>
29+
{noti.reference_id && (
30+
<div className="mt-2">
31+
<Badge variant="outline" className="text-xs uppercase">
32+
<Link href={getLink(noti.reference_type, noti.reference_id)}>
33+
{noti.reference_type}_{noti.reference_id.slice(0, 8)}
34+
</Link>
35+
</Badge>
36+
</div>
37+
)}
38+
</div>
39+
<NotificationAction noti={noti} />
40+
</div>
41+
)
42+
}
43+
44+
const getIcon = (type: string) => {
45+
switch (type) {
46+
case 'BOOK':
47+
return <BookHeart className="h-5 w-5 text-yellow-500" />
48+
case 'BORROWING':
49+
return <BookUser className="h-5 w-5 text-blue-500" />
50+
case 'SUBSCRIPTION':
51+
return <CreditCard className="h-5 w-5 text-green-500" />
52+
default:
53+
return <Bell className="h-5 w-5 text-gray-500" />
54+
}
55+
}
56+
57+
const getLink = (type: string, id: string): Route => {
58+
switch (type) {
59+
case 'BOOK':
60+
return `/books/${id}` as Route
61+
case 'BORROWING':
62+
return `/borrows/${id}` as Route
63+
case 'SUBSCRIPTION':
64+
return `/subscriptions/${id}` as Route
65+
default:
66+
return `/notifications#${id}` as Route
67+
}
68+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client'
2+
3+
import {
4+
DropdownMenu,
5+
DropdownMenuContent,
6+
DropdownMenuItem,
7+
DropdownMenuTrigger,
8+
} from '@/components/ui/dropdown-menu'
9+
import { Button } from '../ui/button'
10+
import { MoreHorizontal } from 'lucide-react'
11+
import { Notification } from '@/lib/types/notification'
12+
import { readNotificationAction } from '@/lib/actions/notification'
13+
14+
export const NotificationAction: React.FC<{
15+
noti: Notification
16+
}> = ({ noti }) => {
17+
return (
18+
<DropdownMenu>
19+
<DropdownMenuTrigger asChild>
20+
<Button variant="ghost" size="icon" className="h-8 w-8">
21+
<MoreHorizontal className="h-4 w-4" />
22+
<span className="sr-only">Actions</span>
23+
</Button>
24+
</DropdownMenuTrigger>
25+
<DropdownMenuContent align="end">
26+
{!noti.read_at && (
27+
<DropdownMenuItem
28+
onClick={readNotificationAction.bind(null, noti.id)}
29+
>
30+
Mark as read
31+
</DropdownMenuItem>
32+
)}
33+
<DropdownMenuItem disabled>Delete notification</DropdownMenuItem>
34+
</DropdownMenuContent>
35+
</DropdownMenu>
36+
)
37+
}

lib/actions/notification.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use server'
22

33
import { revalidatePath } from 'next/cache'
4-
import { readAllNotifications } from '../api/notification'
4+
import { readAllNotifications, readNotification } from '../api/notification'
55
import { Verify } from '../firebase/firebase'
66

77
export async function readAllNotificationsAction() {
@@ -21,3 +21,21 @@ export async function readAllNotificationsAction() {
2121
revalidatePath('/notifications')
2222
}
2323
}
24+
25+
export async function readNotificationAction(id: string) {
26+
const headers = await Verify({ from: '/notifications' })
27+
28+
try {
29+
const res = await readNotification(id, {
30+
headers,
31+
})
32+
return res
33+
} catch (e) {
34+
if (e instanceof Object && 'error' in e) {
35+
return { error: e.error as string }
36+
}
37+
return { error: 'failed to read notification' }
38+
} finally {
39+
revalidatePath('/notifications')
40+
}
41+
}

lib/api/notification.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const streamNotification = (
3737
type GetListNotificationsResponse = Promise<ResList<Notification>>
3838

3939
export const getListNotifications = async (
40-
query: Pick<QueryParams<unknown>, 'skip' | 'limit'>,
40+
query: Pick<QueryParams<unknown>, 'skip' | 'limit'> & { is_unread?: 'true' },
4141
init?: RequestInit
4242
): GetListNotificationsResponse => {
4343
const url = new URL(NOTIFICATION_URL)
@@ -63,14 +63,13 @@ export const getListNotifications = async (
6363

6464
export const readNotification = async (id: string, init?: RequestInit) => {
6565
const url = new URL(`${NOTIFICATION_URL}/${id}/read`)
66+
const headers = new Headers(init?.headers)
67+
headers.set('Content-Type', 'application/json')
6668
try {
6769
const response = await fetch(url.toString(), {
6870
method: 'POST',
69-
headers: {
70-
'Content-Type': 'application/json',
71-
},
71+
headers,
7272
body: JSON.stringify({ read: true }),
73-
...init,
7473
})
7574
if (!response.ok) {
7675
const e = await response.json()

0 commit comments

Comments
 (0)