diff --git a/locales/en-US/notification.json b/locales/en-US/notification.json new file mode 100644 index 0000000000..5d74fb66ce --- /dev/null +++ b/locales/en-US/notification.json @@ -0,0 +1,12 @@ +{ + "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", + "video_generation_completed_title": "Video Generated" +} diff --git a/locales/en-US/setting.json b/locales/en-US/setting.json index 6a7cd57232..363cafe0f1 100644 --- a/locales/en-US/setting.json +++ b/locales/en-US/setting.json @@ -443,6 +443,12 @@ "myAgents.status.published": "Published", "myAgents.status.unpublished": "Unpublished", "myAgents.title": "My Published Agents", + "notification.email.desc": "Receive email notifications when important events occur", + "notification.email.title": "Email Notifications", + "notification.enabled": "Enabled", + "notification.inbox.desc": "Show notifications in the in-app inbox", + "notification.inbox.title": "Inbox Notifications", + "notification.title": "Notification Channels", "plugin.addMCPPlugin": "Add MCP", "plugin.addTooltip": "Custom Skills", "plugin.clearDeprecated": "Remove Deprecated Skills", @@ -807,6 +813,7 @@ "tab.manualFill": "Manually Fill In", "tab.manualFill.desc": "Configure a custom MCP skill manually", "tab.memory": "Memory", + "tab.notification": "Notifications", "tab.profile": "My Account", "tab.provider": "Provider", "tab.proxy": "Proxy", diff --git a/locales/zh-CN/notification.json b/locales/zh-CN/notification.json new file mode 100644 index 0000000000..35600dab87 --- /dev/null +++ b/locales/zh-CN/notification.json @@ -0,0 +1,12 @@ +{ + "image_generation_completed": "图片 \"{{prompt}}\" 已生成完成", + "image_generation_completed_title": "图片已生成", + "inbox.archiveAll": "全部归档", + "inbox.empty": "暂无通知", + "inbox.emptyUnread": "没有未读通知", + "inbox.filterUnread": "仅显示未读", + "inbox.markAllRead": "全部标为已读", + "inbox.title": "通知", + "video_generation_completed": "视频 \"{{prompt}}\" 已生成完成", + "video_generation_completed_title": "视频已生成" +} diff --git a/locales/zh-CN/setting.json b/locales/zh-CN/setting.json index 162ade7a81..533bc26632 100644 --- a/locales/zh-CN/setting.json +++ b/locales/zh-CN/setting.json @@ -443,6 +443,12 @@ "myAgents.status.published": "已上架", "myAgents.status.unpublished": "未上架", "myAgents.title": "我发布的助理", + "notification.email.desc": "当重要事件发生时接收邮件通知", + "notification.email.title": "邮件通知", + "notification.enabled": "启用", + "notification.inbox.desc": "在应用内收件箱中显示通知", + "notification.inbox.title": "站内通知", + "notification.title": "通知渠道", "plugin.addMCPPlugin": "添加 MCP", "plugin.addTooltip": "自定义技能", "plugin.clearDeprecated": "移除无效技能", @@ -807,6 +813,7 @@ "tab.manualFill": "自行填写内容", "tab.manualFill.desc": "手动配置自定义 MCP 技能", "tab.memory": "记忆设置", + "tab.notification": "通知", "tab.profile": "我的账号", "tab.provider": "AI 服务商", "tab.proxy": "网络代理", diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index ee819559a2..2a5c88f293 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -5,6 +5,7 @@ import { DEFAULT_HOTKEY_CONFIG, DEFAULT_IMAGE_CONFIG, DEFAULT_MEMORY_SETTINGS, + DEFAULT_NOTIFICATION_SETTINGS, DEFAULT_SYSTEM_AGENT_CONFIG, DEFAULT_TOOL_CONFIG, DEFAULT_TTS_CONFIG, @@ -19,6 +20,7 @@ export const DEFAULT_SETTINGS: UserSettings = { keyVaults: {}, languageModel: DEFAULT_LLM_CONFIG, memory: DEFAULT_MEMORY_SETTINGS, + notification: DEFAULT_NOTIFICATION_SETTINGS, systemAgent: DEFAULT_SYSTEM_AGENT_CONFIG, tool: DEFAULT_TOOL_CONFIG, tts: DEFAULT_TTS_CONFIG, diff --git a/packages/const/src/settings/index.ts b/packages/const/src/settings/index.ts index 7212439108..f854f979e9 100644 --- a/packages/const/src/settings/index.ts +++ b/packages/const/src/settings/index.ts @@ -6,6 +6,7 @@ export * from './image'; export * from './knowledge'; export * from './llm'; export * from './memory'; +export * from './notification'; export * from './systemAgent'; export * from './tool'; export * from './tts'; diff --git a/packages/const/src/settings/notification.ts b/packages/const/src/settings/notification.ts new file mode 100644 index 0000000000..02e9dccc19 --- /dev/null +++ b/packages/const/src/settings/notification.ts @@ -0,0 +1,22 @@ +import type { NotificationSettings } from '@lobechat/types'; + +export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { + email: { + enabled: true, + items: { + generation: { + image_generation_completed: true, + video_generation_completed: true, + }, + }, + }, + inbox: { + enabled: true, + items: { + generation: { + image_generation_completed: true, + video_generation_completed: true, + }, + }, + }, +}; diff --git a/packages/database/src/models/notification.ts b/packages/database/src/models/notification.ts new file mode 100644 index 0000000000..c6b47706de --- /dev/null +++ b/packages/database/src/models/notification.ts @@ -0,0 +1,128 @@ +import { and, count, desc, eq, inArray, lt, or } from 'drizzle-orm'; + +import type { NewNotification, NewNotificationDelivery } from '../schemas/notification'; +import { notificationDeliveries, notifications } from '../schemas/notification'; +import type { LobeChatDatabase } from '../type'; + +export class NotificationModel { + private readonly userId: string; + private readonly db: LobeChatDatabase; + + constructor(db: LobeChatDatabase, userId: string) { + this.db = db; + this.userId = userId; + } + + 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)); + } + + if (cursor) { + const cursorRow = await this.db + .select({ createdAt: notifications.createdAt, id: notifications.id }) + .from(notifications) + .where(and(eq(notifications.id, cursor), eq(notifications.userId, this.userId))) + .limit(1); + + if (cursorRow[0]) { + // Composite cursor to handle identical createdAt timestamps + const { createdAt: cursorTime, id: cursorId } = cursorRow[0]; + conditions.push( + or( + lt(notifications.createdAt, cursorTime), + and(eq(notifications.createdAt, cursorTime), lt(notifications.id, cursorId)), + )!, + ); + } + } + + return this.db + .select() + .from(notifications) + .where(and(...conditions)) + .orderBy(desc(notifications.createdAt), desc(notifications.id)) + .limit(limit); + } + + async getUnreadCount(): Promise { + const [result] = await this.db + .select({ count: count() }) + .from(notifications) + .where( + and( + eq(notifications.userId, this.userId), + eq(notifications.isRead, false), + eq(notifications.isArchived, false), + ), + ); + + return result?.count ?? 0; + } + + async markAsRead(ids: string[]) { + if (ids.length === 0) return; + + return this.db + .update(notifications) + .set({ isRead: true, updatedAt: new Date() }) + .where(and(eq(notifications.userId, this.userId), inArray(notifications.id, ids))); + } + + async markAllAsRead() { + return this.db + .update(notifications) + .set({ isRead: true, updatedAt: new Date() }) + .where( + and( + eq(notifications.userId, this.userId), + eq(notifications.isRead, false), + eq(notifications.isArchived, false), + ), + ); + } + + async archive(id: string) { + return this.db + .update(notifications) + .set({ isArchived: true, updatedAt: new Date() }) + .where(and(eq(notifications.id, id), eq(notifications.userId, this.userId))); + } + + async archiveAll() { + return this.db + .update(notifications) + .set({ isArchived: true, updatedAt: new Date() }) + .where(and(eq(notifications.userId, this.userId), eq(notifications.isArchived, false))); + } + + // ─── Write-side (used by NotificationService in cloud) ───────── + + async create(data: Omit) { + const [result] = await this.db + .insert(notifications) + .values({ ...data, userId: this.userId }) + .onConflictDoNothing({ + target: [notifications.userId, notifications.dedupeKey], + }) + .returning(); + + return result ?? null; + } + + async createDelivery(data: NewNotificationDelivery) { + const [result] = await this.db.insert(notificationDeliveries).values(data).returning(); + + return result; + } +} diff --git a/packages/types/src/user/settings/index.ts b/packages/types/src/user/settings/index.ts index 0796940e17..effb416126 100644 --- a/packages/types/src/user/settings/index.ts +++ b/packages/types/src/user/settings/index.ts @@ -8,6 +8,7 @@ import type { UserKeyVaults } from './keyVaults'; import type { MarketAuthTokens } from './market'; import type { UserMemorySettings } from './memory'; import type { UserModelProviderConfig } from './modelProvider'; +import type { NotificationSettings } from './notification'; import type { UserSystemAgentConfig } from './systemAgent'; import type { UserToolConfig } from './tool'; import type { UserTTSConfig } from './tts'; @@ -22,6 +23,7 @@ export * from './keyVaults'; export * from './market'; export * from './memory'; export * from './modelProvider'; +export * from './notification'; export * from './sync'; export * from './systemAgent'; export * from './tool'; @@ -39,6 +41,7 @@ export interface UserSettings { languageModel: UserModelProviderConfig; market?: MarketAuthTokens; memory?: UserMemorySettings; + notification?: NotificationSettings; systemAgent: UserSystemAgentConfig; tool: UserToolConfig; tts: UserTTSConfig; @@ -58,6 +61,7 @@ export const UserSettingsSchema = z languageModel: z.any().optional(), market: z.any().optional(), memory: z.any().optional(), + notification: z.any().optional(), systemAgent: z.any().optional(), tool: z.any().optional(), tts: z.any().optional(), diff --git a/packages/types/src/user/settings/notification.ts b/packages/types/src/user/settings/notification.ts new file mode 100644 index 0000000000..63335776f0 --- /dev/null +++ b/packages/types/src/user/settings/notification.ts @@ -0,0 +1,10 @@ +export interface NotificationChannelSettings { + enabled?: boolean; + /** Per-type overrides grouped by category. Missing = use scenario default (true) */ + items?: Record>; +} + +export interface NotificationSettings { + email?: NotificationChannelSettings; + inbox?: NotificationChannelSettings; +} diff --git a/src/app/(backend)/api/webhooks/video/[provider]/route.ts b/src/app/(backend)/api/webhooks/video/[provider]/route.ts index 2e71332935..470c335001 100644 --- a/src/app/(backend)/api/webhooks/video/[provider]/route.ts +++ b/src/app/(backend)/api/webhooks/video/[provider]/route.ts @@ -15,6 +15,7 @@ import { type RuntimeVideoGenParams } from 'model-bank'; import { NextResponse } from 'next/server'; import { chargeAfterGenerate } from '@/business/server/video-generation/chargeAfterGenerate'; +import { notifyVideoCompleted } from '@/business/server/video-generation/notifyVideoCompleted'; import { AsyncTaskModel } from '@/database/models/asyncTask'; import { GenerationModel } from '@/database/models/generation'; import { generationBatches } from '@/database/schemas'; @@ -201,6 +202,14 @@ export const POST = async (req: Request, { params }: { params: Promise<{ provide status: AsyncTaskStatus.Success, }); + notifyVideoCompleted({ + generationBatchId: generation.generationBatchId!, + model: resolvedModel, + prompt: batch?.prompt ?? '', + topicId: batch?.generationTopicId, + userId: asyncTask.userId, + }).catch((err) => console.error('[video-webhook] notification failed:', err)); + // Charge after successful video generation try { await chargeAfterGenerate({ diff --git a/src/business/client/BusinessSettingPages/Notification.tsx b/src/business/client/BusinessSettingPages/Notification.tsx new file mode 100644 index 0000000000..cad547ff44 --- /dev/null +++ b/src/business/client/BusinessSettingPages/Notification.tsx @@ -0,0 +1,3 @@ +const Notification = () => null; + +export default Notification; diff --git a/src/business/server/image-generation/notifyImageCompleted.ts b/src/business/server/image-generation/notifyImageCompleted.ts new file mode 100644 index 0000000000..44525841fe --- /dev/null +++ b/src/business/server/image-generation/notifyImageCompleted.ts @@ -0,0 +1,11 @@ +interface NotifyImageCompletedParams { + duration: number; + generationBatchId: string; + model: string; + prompt: string; + topicId?: string; + userId: string; +} + +// eslint-disable-next-line unused-imports/no-unused-vars +export async function notifyImageCompleted(params: NotifyImageCompletedParams): Promise {} diff --git a/src/business/server/video-generation/notifyVideoCompleted.ts b/src/business/server/video-generation/notifyVideoCompleted.ts new file mode 100644 index 0000000000..b49d1d5e0a --- /dev/null +++ b/src/business/server/video-generation/notifyVideoCompleted.ts @@ -0,0 +1,10 @@ +interface NotifyVideoCompletedParams { + generationBatchId: string; + model: string; + prompt: string; + topicId?: string; + userId: string; +} + +// eslint-disable-next-line unused-imports/no-unused-vars +export async function notifyVideoCompleted(params: NotifyVideoCompletedParams): Promise {} diff --git a/src/features/NavPanel/SideBarHeaderLayout.tsx b/src/features/NavPanel/SideBarHeaderLayout.tsx index fabed7e338..999df3e4fb 100644 --- a/src/features/NavPanel/SideBarHeaderLayout.tsx +++ b/src/features/NavPanel/SideBarHeaderLayout.tsx @@ -128,15 +128,7 @@ const SideBarHeaderLayout = memo( padding={6} > {leftContent} - + {showTogglePanelButton && } {right} diff --git a/src/locales/default/index.ts b/src/locales/default/index.ts index d5b717a854..2cdbeeaccd 100644 --- a/src/locales/default/index.ts +++ b/src/locales/default/index.ts @@ -25,6 +25,7 @@ import metadata from './metadata'; import migration from './migration'; import modelProvider from './modelProvider'; import models from './models'; +import notification from './notification'; import oauth from './oauth'; import onboarding from './onboarding'; import plugin from './plugin'; @@ -72,6 +73,7 @@ const resources = { migration, modelProvider, models, + notification, oauth, onboarding, plugin, diff --git a/src/locales/default/notification.ts b/src/locales/default/notification.ts new file mode 100644 index 0000000000..d464ae0bf6 --- /dev/null +++ b/src/locales/default/notification.ts @@ -0,0 +1,12 @@ +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', + 'video_generation_completed_title': 'Video Generated', +} as const; diff --git a/src/locales/default/setting.ts b/src/locales/default/setting.ts index 5448bd9579..b4dfc7fd81 100644 --- a/src/locales/default/setting.ts +++ b/src/locales/default/setting.ts @@ -450,6 +450,12 @@ export default { 'memory.enabled.title': 'Enable Memory', 'memory.title': 'Memory Settings', 'message.success': 'Update successful', + 'notification.enabled': 'Enabled', + 'notification.email.desc': 'Receive email notifications when important events occur', + 'notification.email.title': 'Email Notifications', + 'notification.inbox.desc': 'Show notifications in the in-app inbox', + 'notification.inbox.title': 'Inbox Notifications', + 'notification.title': 'Notification Channels', 'myAgents.actions.cancel': 'Cancel', 'myAgents.actions.confirmDeprecate': 'Confirm Deprecate', 'myAgents.actions.deprecate': 'Deprecate Permanently', @@ -927,6 +933,7 @@ When I am ___, I need ___ 'tab.manualFill': 'Manually Fill In', 'tab.manualFill.desc': 'Configure a custom MCP skill manually', 'tab.memory': 'Memory', + 'tab.notification': 'Notifications', 'tab.profile': 'My Account', 'tab.provider': 'Provider', 'tab.proxy': 'Proxy', diff --git a/src/routes/(main)/home/_layout/Header/components/InboxButton.tsx b/src/routes/(main)/home/_layout/Header/components/InboxButton.tsx new file mode 100644 index 0000000000..829d0f98dc --- /dev/null +++ b/src/routes/(main)/home/_layout/Header/components/InboxButton.tsx @@ -0,0 +1,53 @@ +'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 { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig'; + +import InboxDrawer from './InboxDrawer'; +import { UNREAD_COUNT_KEY } from './InboxDrawer/constants'; + +const InboxButton = memo(() => { + const { t } = useTranslation('notification'); + const [open, setOpen] = useState(false); + const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures); + + const { data: unreadCount = 0 } = useClientDataSWR( + enableBusinessFeatures ? UNREAD_COUNT_KEY : null, + () => notificationService.getUnreadCount(), + { refreshInterval: 10_000 }, + ); + + const handleToggle = useCallback(() => { + setOpen((prev) => !prev); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + if (!enableBusinessFeatures) return null; + + return ( + <> + + + + + + ); +}); + +export default InboxButton; diff --git a/src/routes/(main)/home/_layout/Header/components/InboxDrawer/Content.tsx b/src/routes/(main)/home/_layout/Header/components/InboxDrawer/Content.tsx new file mode 100644 index 0000000000..9d42bd78ed --- /dev/null +++ b/src/routes/(main)/home/_layout/Header/components/InboxDrawer/Content.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { Flexbox, Icon, Text } from '@lobehub/ui'; +import { cssVar } from 'antd-style'; +import { BellOffIcon } from 'lucide-react'; +import { memo, useCallback, useEffect, 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; + unreadOnly?: boolean; +} + +const Content = memo(({ open, unreadOnly, onMarkAsRead, onArchive }) => { + const { t } = useTranslation('notification'); + const virtuaRef = useRef(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, undefined, unreadOnly] as const; + + const lastItem = previousPageData?.at(-1); + return [FETCH_KEY, lastItem?.id, unreadOnly] as const; + }, + [open, unreadOnly], + ); + + const { + data: pages, + isLoading, + isValidating, + setSize, + } = 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; + + 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 ( + + + + ); + } + + if (notifications.length === 0) { + return ( + + + {t(unreadOnly ? 'inbox.emptyUnread' : 'inbox.empty')} + + ); + } + + return ( + + {notifications.map((item) => ( + + + + ))} + {isValidating && ( + + + + )} + + ); +}); + +Content.displayName = 'InboxDrawerContent'; + +export default Content; diff --git a/src/routes/(main)/home/_layout/Header/components/InboxDrawer/NotificationItem.tsx b/src/routes/(main)/home/_layout/Header/components/InboxDrawer/NotificationItem.tsx new file mode 100644 index 0000000000..5e76f19033 --- /dev/null +++ b/src/routes/(main)/home/_layout/Header/components/InboxDrawer/NotificationItem.tsx @@ -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 = { + 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( + ({ 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 ( + + + + + + + {!isRead && } + + {title} + + + + + + + + {dayjs(createdAt).fromNow()} + + + + + {content} + + + + + ); + }, +); + +export default NotificationItem; diff --git a/src/routes/(main)/home/_layout/Header/components/InboxDrawer/constants.ts b/src/routes/(main)/home/_layout/Header/components/InboxDrawer/constants.ts new file mode 100644 index 0000000000..f7bed4866b --- /dev/null +++ b/src/routes/(main)/home/_layout/Header/components/InboxDrawer/constants.ts @@ -0,0 +1,2 @@ +export const UNREAD_COUNT_KEY = 'inbox-unread-count'; +export const FETCH_KEY = 'inbox-notifications'; diff --git a/src/routes/(main)/home/_layout/Header/components/InboxDrawer/index.tsx b/src/routes/(main)/home/_layout/Header/components/InboxDrawer/index.tsx new file mode 100644 index 0000000000..052c569fc6 --- /dev/null +++ b/src/routes/(main)/home/_layout/Header/components/InboxDrawer/index.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { ActionIcon, Flexbox } from '@lobehub/ui'; +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'; +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: () => ( + + + + ), + ssr: false, +}); + +interface InboxDrawerProps { + onClose: () => void; + open: boolean; +} + +const InboxDrawer = memo(({ open, onClose }) => { + const { t } = useTranslation('notification'); + const [unreadOnly, setUnreadOnly] = useState(false); + + 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]); + + const handleArchiveAll = useCallback(async () => { + await notificationService.archiveAll(); + refreshList(); + }, [refreshList]); + + const handleToggleFilter = useCallback(() => { + setUnreadOnly((prev) => !prev); + }, []); + + return ( + + + + + + } + onClose={onClose} + > + + + ); +}); + +InboxDrawer.displayName = 'InboxDrawer'; + +export default InboxDrawer; diff --git a/src/routes/(main)/home/_layout/Header/index.tsx b/src/routes/(main)/home/_layout/Header/index.tsx index 83ee165477..b3b95408fc 100644 --- a/src/routes/(main)/home/_layout/Header/index.tsx +++ b/src/routes/(main)/home/_layout/Header/index.tsx @@ -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 ( <> - } right={} showBack={false} /> + } + showBack={false} + right={ + <> + + + + } + />