mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ 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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"image_generation_completed": "图片 \"{{prompt}}\" 已生成完成",
|
||||
"image_generation_completed_title": "图片已生成",
|
||||
"inbox": {
|
||||
"empty": "暂无通知",
|
||||
"markAllRead": "全部标为已读",
|
||||
"title": "通知"
|
||||
},
|
||||
"video_generation_completed": "视频 \"{{prompt}}\" 已生成完成",
|
||||
"video_generation_completed_title": "视频已生成"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const UNREAD_COUNT_KEY = 'inbox-unread-count';
|
||||
export const FETCH_KEY = 'inbox-notifications';
|
||||
@@ -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;
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
29
src/services/notification.ts
Normal file
29
src/services/notification.ts
Normal 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();
|
||||
Reference in New Issue
Block a user