feat: add archive all button and unread filter to inbox drawer

This commit is contained in:
YuTengjing
2026-03-25 19:51:44 +08:00
parent 7f170b7658
commit 48819b5bbf
8 changed files with 83 additions and 28 deletions

View File

@@ -1,7 +1,10 @@
{
"image_generation_completed": "Image \"{{prompt}}\" generated successfully",
"image_generation_completed_title": "Image Generated",
"inbox.archiveAll": "Archive all",
"inbox.empty": "No notifications yet",
"inbox.emptyUnread": "No unread notifications",
"inbox.filterUnread": "Show unread only",
"inbox.markAllRead": "Mark all as read",
"inbox.title": "Notifications",
"video_generation_completed": "Video \"{{prompt}}\" generated successfully",

View File

@@ -1,7 +1,10 @@
{
"image_generation_completed": "图片 \"{{prompt}}\" 已生成完成",
"image_generation_completed_title": "图片已生成",
"inbox.archiveAll": "全部归档",
"inbox.empty": "暂无通知",
"inbox.emptyUnread": "没有未读通知",
"inbox.filterUnread": "仅显示未读",
"inbox.markAllRead": "全部标为已读",
"inbox.title": "通知",
"video_generation_completed": "视频 \"{{prompt}}\" 已生成完成",

View File

@@ -13,11 +13,17 @@ export class NotificationModel {
this.userId = userId;
}
async list(opts: { category?: string; cursor?: string; limit?: number } = {}) {
const { cursor, limit = 20, category } = opts;
async list(
opts: { category?: string; cursor?: string; limit?: number; unreadOnly?: boolean } = {},
) {
const { cursor, limit = 20, category, unreadOnly } = opts;
const conditions = [eq(notifications.userId, this.userId), eq(notifications.isArchived, false)];
if (unreadOnly) {
conditions.push(eq(notifications.isRead, false));
}
if (category) {
conditions.push(eq(notifications.category, category));
}

View File

@@ -1,7 +1,10 @@
export default {
'image_generation_completed': 'Image "{{prompt}}" generated successfully',
'image_generation_completed_title': 'Image Generated',
'inbox.archiveAll': 'Archive all',
'inbox.empty': 'No notifications yet',
'inbox.emptyUnread': 'No unread notifications',
'inbox.filterUnread': 'Show unread only',
'inbox.markAllRead': 'Mark all as read',
'inbox.title': 'Notifications',
'video_generation_completed': 'Video "{{prompt}}" generated successfully',

View File

@@ -3,7 +3,7 @@
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { BellOffIcon } from 'lucide-react';
import { memo, useCallback, useRef } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import useSWRInfinite from 'swr/infinite';
import { VList, type VListHandle } from 'virtua';
@@ -20,9 +20,10 @@ interface ContentProps {
onArchive: (id: string) => void;
onMarkAsRead: (id: string) => void;
open: boolean;
unreadOnly?: boolean;
}
const Content = memo<ContentProps>(({ open, onMarkAsRead, onArchive }) => {
const Content = memo<ContentProps>(({ open, unreadOnly, onMarkAsRead, onArchive }) => {
const { t } = useTranslation('notification');
const virtuaRef = useRef<VListHandle>(null);
@@ -31,12 +32,12 @@ const Content = memo<ContentProps>(({ open, onMarkAsRead, onArchive }) => {
if (!open) return null;
if (previousPageData && previousPageData.length < PAGE_SIZE) return null;
if (pageIndex === 0) return [FETCH_KEY] as const;
if (pageIndex === 0) return [FETCH_KEY, undefined, unreadOnly] as const;
const lastItem = previousPageData?.at(-1);
return [FETCH_KEY, lastItem?.id] as const;
return [FETCH_KEY, lastItem?.id, unreadOnly] as const;
},
[open],
[open, unreadOnly],
);
const {
@@ -44,16 +45,19 @@ const Content = memo<ContentProps>(({ open, onMarkAsRead, onArchive }) => {
isLoading,
isValidating,
setSize,
} = useSWRInfinite(
getKey,
async ([, cursor]) => {
return notificationService.list({
cursor: cursor as string | undefined,
limit: PAGE_SIZE,
});
},
{ dedupingInterval: 0 },
);
} = useSWRInfinite(getKey, async ([, cursor, filterUnread]) => {
return notificationService.list({
cursor: cursor as string | undefined,
limit: PAGE_SIZE,
unreadOnly: filterUnread,
});
});
// Reset scroll position and pagination when filter changes
useEffect(() => {
setSize(1);
virtuaRef.current?.scrollTo(0);
}, [unreadOnly, setSize]);
const notifications = pages?.flat() ?? [];
const hasMore = pages ? pages.at(-1)?.length === PAGE_SIZE : false;
@@ -80,7 +84,7 @@ const Content = memo<ContentProps>(({ open, onMarkAsRead, onArchive }) => {
return (
<Flexbox align="center" gap={12} justify="center" paddingBlock={48}>
<Icon color={cssVar.colorTextQuaternary} icon={BellOffIcon} size={40} />
<Text type="secondary">{t('inbox.empty')}</Text>
<Text type="secondary">{t(unreadOnly ? 'inbox.emptyUnread' : 'inbox.empty')}</Text>
</Flexbox>
);
}

View File

@@ -1,8 +1,8 @@
'use client';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { CheckCheckIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { ArchiveIcon, CheckCheckIcon, ListFilterIcon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
@@ -30,6 +30,7 @@ interface InboxDrawerProps {
const InboxDrawer = memo<InboxDrawerProps>(({ open, onClose }) => {
const { t } = useTranslation('notification');
const [unreadOnly, setUnreadOnly] = useState(false);
const refreshList = useCallback(() => {
mutate((key: unknown) => Array.isArray(key) && key[0] === FETCH_KEY);
@@ -57,21 +58,50 @@ const InboxDrawer = memo<InboxDrawerProps>(({ open, onClose }) => {
refreshList();
}, [refreshList]);
const handleArchiveAll = useCallback(async () => {
await notificationService.archiveAll();
refreshList();
}, [refreshList]);
const handleToggleFilter = useCallback(() => {
setUnreadOnly((prev) => !prev);
}, []);
return (
<SideBarDrawer
open={open}
title={t('inbox.title')}
action={
<ActionIcon
icon={CheckCheckIcon}
size={DESKTOP_HEADER_ICON_SIZE}
title={t('inbox.markAllRead')}
onClick={handleMarkAllAsRead}
/>
<>
<ActionIcon
icon={ArchiveIcon}
size={DESKTOP_HEADER_ICON_SIZE}
title={t('inbox.archiveAll')}
onClick={handleArchiveAll}
/>
<ActionIcon
icon={CheckCheckIcon}
size={DESKTOP_HEADER_ICON_SIZE}
title={t('inbox.markAllRead')}
onClick={handleMarkAllAsRead}
/>
<ActionIcon
active={unreadOnly}
icon={ListFilterIcon}
size={DESKTOP_HEADER_ICON_SIZE}
title={t('inbox.filterUnread')}
onClick={handleToggleFilter}
/>
</>
}
onClose={onClose}
>
<Content open={open} onArchive={handleArchive} onMarkAsRead={handleMarkAsRead} />
<Content
open={open}
unreadOnly={unreadOnly}
onArchive={handleArchive}
onMarkAsRead={handleMarkAsRead}
/>
</SideBarDrawer>
);
});

View File

@@ -29,6 +29,7 @@ export const notificationRouter = router({
category: z.string().optional(),
cursor: z.string().optional(),
limit: z.number().min(1).max(50).default(20),
unreadOnly: z.boolean().optional(),
}),
)
.query(async ({ ctx, input }) => {

View File

@@ -1,7 +1,12 @@
import { lambdaClient } from '@/libs/trpc/client';
class NotificationService {
list = (params?: { category?: string; cursor?: string; limit?: number }) => {
list = (params?: {
category?: string;
cursor?: string;
limit?: number;
unreadOnly?: boolean;
}) => {
return lambdaClient.notification.list.query(params);
};