feat: add inbox notification UI with sidebar drawer

- Add notification service (TRPC client wrapper)
- Add InboxButton with bell icon + unread count badge (10s polling)
- Add InboxDrawer using SideBarDrawer pattern (same as AllTopicsDrawer)
- Add NotificationItem with type-based icons, unread dot, archive action
- Add VList infinite scroll with cursor-based pagination
- Add i18n keys for inbox UI (en-US, zh-CN)
This commit is contained in:
YuTengjing
2026-03-24 23:49:06 +08:00
parent fbd5e4cb1c
commit 0928901c1c
10 changed files with 425 additions and 1 deletions

View File

@@ -1,6 +1,11 @@
{
"image_generation_completed": "Image \"{{prompt}}\" generated successfully",
"image_generation_completed_title": "Image Generated",
"inbox": {
"empty": "No notifications yet",
"markAllRead": "Mark all as read",
"title": "Notifications"
},
"video_generation_completed": "Video \"{{prompt}}\" generated successfully",
"video_generation_completed_title": "Video Generated"
}

View File

@@ -1,6 +1,11 @@
{
"image_generation_completed": "图片 \"{{prompt}}\" 已生成完成",
"image_generation_completed_title": "图片已生成",
"inbox": {
"empty": "暂无通知",
"markAllRead": "全部标为已读",
"title": "通知"
},
"video_generation_completed": "视频 \"{{prompt}}\" 已生成完成",
"video_generation_completed_title": "视频已生成"
}

View File

@@ -1,6 +1,11 @@
export default {
image_generation_completed: 'Image "{{prompt}}" generated successfully',
image_generation_completed_title: 'Image Generated',
inbox: {
empty: 'No notifications yet',
markAllRead: 'Mark all as read',
title: 'Notifications',
},
video_generation_completed: 'Video "{{prompt}}" generated successfully',
video_generation_completed_title: 'Video Generated',
} as const;

View File

@@ -0,0 +1,49 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { Badge } from 'antd';
import { BellIcon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import { useClientDataSWR } from '@/libs/swr';
import { notificationService } from '@/services/notification';
import InboxDrawer from './InboxDrawer';
import { UNREAD_COUNT_KEY } from './InboxDrawer/constants';
const InboxButton = memo(() => {
const { t } = useTranslation('notification');
const [open, setOpen] = useState(false);
const { data: unreadCount = 0 } = useClientDataSWR<number>(
UNREAD_COUNT_KEY,
() => notificationService.getUnreadCount(),
{ refreshInterval: 10_000 },
);
const handleToggle = useCallback(() => {
setOpen((prev) => !prev);
}, []);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
return (
<>
<Badge count={unreadCount} offset={[-4, 4]} size="small">
<ActionIcon
icon={BellIcon}
size={DESKTOP_HEADER_ICON_SIZE}
title={t('inbox.title')}
onClick={handleToggle}
/>
</Badge>
<InboxDrawer open={open} onClose={handleClose} />
</>
);
});
export default InboxButton;

View File

@@ -0,0 +1,116 @@
'use client';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { BellOffIcon } from 'lucide-react';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import useSWRInfinite from 'swr/infinite';
import { VList, type VListHandle } from 'virtua';
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import { notificationService } from '@/services/notification';
import { FETCH_KEY } from './constants';
import NotificationItem from './NotificationItem';
const PAGE_SIZE = 20;
interface ContentProps {
onArchive: (id: string) => void;
onMarkAsRead: (id: string) => void;
open: boolean;
}
const Content = memo<ContentProps>(({ open, onMarkAsRead, onArchive }) => {
const { t } = useTranslation('notification');
const virtuaRef = useRef<VListHandle>(null);
const getKey = useCallback(
(pageIndex: number, previousPageData: any[] | null) => {
if (!open) return null;
if (previousPageData && previousPageData.length < PAGE_SIZE) return null;
if (pageIndex === 0) return [FETCH_KEY] as const;
const lastItem = previousPageData?.at(-1);
return [FETCH_KEY, lastItem?.id] as const;
},
[open],
);
const {
data: pages,
isLoading,
isValidating,
setSize,
} = useSWRInfinite(
getKey,
async ([, cursor]) => {
return notificationService.list({
cursor: cursor as string | undefined,
limit: PAGE_SIZE,
});
},
{ dedupingInterval: 0 },
);
const notifications = pages?.flat() ?? [];
const hasMore = pages ? pages.at(-1)?.length === PAGE_SIZE : false;
const handleScroll = useCallback(() => {
const ref = virtuaRef.current;
if (!ref || !hasMore || isValidating) return;
const bottomVisibleIndex = ref.findItemIndex(ref.scrollOffset + ref.viewportSize);
if (bottomVisibleIndex + 5 > notifications.length) {
setSize((prev) => prev + 1);
}
}, [hasMore, isValidating, notifications.length, setSize]);
if (isLoading) {
return (
<Flexbox gap={1} paddingBlock={1} paddingInline={4}>
<SkeletonList rows={5} />
</Flexbox>
);
}
if (notifications.length === 0) {
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>
</Flexbox>
);
}
return (
<VList ref={virtuaRef} style={{ height: '100%' }} onScroll={handleScroll}>
{notifications.map((item) => (
<Flexbox key={item.id} padding="4px 8px">
<NotificationItem
actionUrl={item.actionUrl}
content={item.content}
createdAt={item.createdAt}
id={item.id}
isRead={item.isRead}
title={item.title}
type={item.type}
onArchive={onArchive}
onMarkAsRead={onMarkAsRead}
/>
</Flexbox>
))}
{isValidating && (
<Flexbox padding="4px 8px">
<SkeletonList rows={2} />
</Flexbox>
)}
</VList>
);
});
Content.displayName = 'InboxDrawerContent';
export default Content;

View File

@@ -0,0 +1,122 @@
'use client';
import { ActionIcon, Block, Flexbox, Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import dayjs from 'dayjs';
import { ArchiveIcon, BellIcon, ImageIcon, VideoIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
const ACTION_CLASS_NAME = 'notification-item-actions';
const styles = createStaticStyles(({ css }) => ({
container: css`
cursor: pointer;
user-select: none;
.${ACTION_CLASS_NAME} {
opacity: 0;
transition: opacity 0.2s ${cssVar.motionEaseOut};
}
&:hover {
.${ACTION_CLASS_NAME} {
opacity: 1;
}
}
`,
unreadDot: css`
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: ${cssVar.colorPrimary};
`,
}));
const TYPE_ICON_MAP: Record<string, typeof BellIcon> = {
image_generation_completed: ImageIcon,
video_generation_completed: VideoIcon,
};
interface NotificationItemProps {
actionUrl?: string | null;
content: string;
createdAt: Date | string;
id: string;
isRead: boolean;
onArchive: (id: string) => void;
onMarkAsRead: (id: string) => void;
title: string;
type: string;
}
const NotificationItem = memo<NotificationItemProps>(
({ id, type, title, content, createdAt, isRead, actionUrl, onMarkAsRead, onArchive }) => {
const navigate = useNavigate();
const TypeIcon = TYPE_ICON_MAP[type] || BellIcon;
const handleClick = useCallback(() => {
if (!isRead) onMarkAsRead(id);
if (actionUrl) navigate(actionUrl);
}, [id, isRead, actionUrl, onMarkAsRead, navigate]);
const handleArchive = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onArchive(id);
},
[id, onArchive],
);
return (
<Block
clickable
className={styles.container}
gap={4}
paddingBlock={8}
paddingInline={12}
variant="borderless"
onClick={handleClick}
>
<Flexbox horizontal align="flex-start" gap={8}>
<Icon
color={cssVar.colorTextDescription}
icon={TypeIcon}
size={18}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Flexbox flex={1} gap={4} style={{ overflow: 'hidden' }}>
<Flexbox horizontal align="center" gap={4} justify="space-between">
<Flexbox horizontal align="center" flex={1} gap={6} style={{ overflow: 'hidden' }}>
{!isRead && <span className={styles.unreadDot} />}
<Text ellipsis style={{ fontWeight: isRead ? 400 : 600 }}>
{title}
</Text>
</Flexbox>
<Flexbox horizontal align="center" gap={2} style={{ flexShrink: 0 }}>
<span className={ACTION_CLASS_NAME}>
<ActionIcon
icon={ArchiveIcon}
size={{ blockSize: 24, size: 14 }}
onClick={handleArchive}
/>
</span>
<Text fontSize={12} style={{ flexShrink: 0 }} type="secondary">
{dayjs(createdAt).fromNow()}
</Text>
</Flexbox>
</Flexbox>
<Text ellipsis fontSize={12} type="secondary">
{content}
</Text>
</Flexbox>
</Flexbox>
</Block>
);
},
);
export default NotificationItem;

View File

@@ -0,0 +1,2 @@
export const UNREAD_COUNT_KEY = 'inbox-unread-count';
export const FETCH_KEY = 'inbox-notifications';

View File

@@ -0,0 +1,81 @@
'use client';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { CheckCheckIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import SideBarDrawer from '@/features/NavPanel/SideBarDrawer';
import dynamic from '@/libs/next/dynamic';
import { mutate } from '@/libs/swr';
import { notificationService } from '@/services/notification';
import { FETCH_KEY, UNREAD_COUNT_KEY } from './constants';
const Content = dynamic(() => import('./Content'), {
loading: () => (
<Flexbox gap={1} paddingBlock={1} paddingInline={4}>
<SkeletonList rows={3} />
</Flexbox>
),
ssr: false,
});
interface InboxDrawerProps {
onClose: () => void;
open: boolean;
}
const InboxDrawer = memo<InboxDrawerProps>(({ open, onClose }) => {
const { t } = useTranslation('notification');
const refreshList = useCallback(() => {
mutate((key: unknown) => Array.isArray(key) && key[0] === FETCH_KEY);
mutate(UNREAD_COUNT_KEY);
}, []);
const handleMarkAsRead = useCallback(
async (id: string) => {
await notificationService.markAsRead([id]);
refreshList();
},
[refreshList],
);
const handleArchive = useCallback(
async (id: string) => {
await notificationService.archive(id);
refreshList();
},
[refreshList],
);
const handleMarkAllAsRead = useCallback(async () => {
await notificationService.markAllAsRead();
refreshList();
}, [refreshList]);
return (
<SideBarDrawer
open={open}
title={t('inbox.title')}
action={
<ActionIcon
icon={CheckCheckIcon}
size={DESKTOP_HEADER_ICON_SIZE}
title={t('inbox.markAllRead')}
onClick={handleMarkAllAsRead}
/>
}
onClose={onClose}
>
<Content open={open} onArchive={handleArchive} onMarkAsRead={handleMarkAsRead} />
</SideBarDrawer>
);
});
InboxDrawer.displayName = 'InboxDrawer';
export default InboxDrawer;

View File

@@ -5,13 +5,23 @@ import { memo } from 'react';
import SideBarHeaderLayout from '@/features/NavPanel/SideBarHeaderLayout';
import AddButton from './components/AddButton';
import InboxButton from './components/InboxButton';
import Nav from './components/Nav';
import User from './components/User';
const Header = memo(() => {
return (
<>
<SideBarHeaderLayout left={<User />} right={<AddButton />} showBack={false} />
<SideBarHeaderLayout
left={<User />}
showBack={false}
right={
<>
<InboxButton />
<AddButton />
</>
}
/>
<Nav />
</>
);

View File

@@ -0,0 +1,29 @@
import { lambdaClient } from '@/libs/trpc/client';
class NotificationService {
list = (params?: { category?: string; cursor?: string; limit?: number }) => {
return lambdaClient.notification.list.query(params);
};
getUnreadCount = (): Promise<number> => {
return lambdaClient.notification.unreadCount.query();
};
markAsRead = (ids: string[]) => {
return lambdaClient.notification.markAsRead.mutate({ ids });
};
markAllAsRead = () => {
return lambdaClient.notification.markAllAsRead.mutate();
};
archive = (id: string) => {
return lambdaClient.notification.archive.mutate({ id });
};
archiveAll = () => {
return lambdaClient.notification.archiveAll.mutate();
};
}
export const notificationService = new NotificationService();