mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-10 22:32:16 +02:00
refactor(layout): remove Inbox component and integrate NotificationsDropdown
- Eliminated the Inbox component and its related logic from FreeLayoutDataProvider and LayoutDataProvider. - Introduced NotificationsDropdown to handle notifications in the sidebar and mobile sidebar. - Updated Sidebar and LayoutShell components to accommodate the new notifications structure, enhancing user experience and code clarity.
This commit is contained in:
parent
0592215bad
commit
93a09e1978
9 changed files with 420 additions and 219 deletions
|
|
@ -1,14 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { Inbox } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import type { ChatItem, NavItem, PageUsage, Workspace } from "../types/layout.types";
|
||||
import type { ChatItem, PageUsage, Workspace } from "../types/layout.types";
|
||||
import { LayoutShell } from "../ui/shell";
|
||||
|
||||
interface FreeLayoutDataProviderProps {
|
||||
|
|
@ -47,34 +46,12 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
|
||||
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
|
||||
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
[
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox",
|
||||
icon: Inbox,
|
||||
isActive: false,
|
||||
},
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[]
|
||||
);
|
||||
|
||||
const pageUsage: PageUsage | undefined = quota
|
||||
? { pagesUsed: quota.used, pagesLimit: quota.limit }
|
||||
: undefined;
|
||||
|
||||
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
|
||||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
if (item.title === "Inbox") gate("use the inbox");
|
||||
},
|
||||
[gate]
|
||||
);
|
||||
|
||||
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
|
||||
|
||||
const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]);
|
||||
|
|
@ -87,8 +64,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
onWorkspaceSettings={gatedAction("workspace settings")}
|
||||
onAddWorkspace={gatedAction("create workspaces")}
|
||||
workspace={GUEST_SPACE}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
navItems={[]}
|
||||
chats={[]}
|
||||
activeChatId={null}
|
||||
onNewChat={resetChat}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Boxes, Inbox, SquareTerminal } from "lucide-react";
|
||||
import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -60,17 +60,6 @@ interface LayoutDataProviderProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||
*/
|
||||
function formatInboxCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
}
|
||||
const thousands = Math.floor(count / 1000);
|
||||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
|
|
@ -125,12 +114,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
// Unified slide-out panel state (only one can be open at a time)
|
||||
type SlideoutPanel = "inbox" | null;
|
||||
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
|
||||
|
||||
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
|
||||
|
||||
// Search space dialog state
|
||||
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
|
||||
|
||||
|
|
@ -228,17 +211,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
|
||||
const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);
|
||||
|
||||
// Reset transient slide-out panels when switching workspaces.
|
||||
// Tabs intentionally persist across spaces — opening tabs from multiple
|
||||
// workspaces is a supported flow (browser-tab semantics).
|
||||
const prevWorkspaceIdRef = useRef(workspaceId);
|
||||
useEffect(() => {
|
||||
if (prevWorkspaceIdRef.current !== workspaceId) {
|
||||
prevWorkspaceIdRef.current = workspaceId;
|
||||
setActiveSlideoutPanel(null);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const workspaces: Workspace[] = useMemo(() => {
|
||||
if (!workspacesData || !Array.isArray(workspacesData)) return [];
|
||||
return workspacesData.map((space) => ({
|
||||
|
|
@ -318,9 +290,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
}, [threadsData, workspaceId]);
|
||||
|
||||
// Navigation items
|
||||
// Inbox, Automations, and Artifacts are rendered explicitly below "New chat"
|
||||
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
|
||||
// list). Documents is embedded below Recents; announcements live in the avatar dropdown.
|
||||
// Automations and Artifacts are rendered explicitly below "New chat"
|
||||
// in the sidebar. Documents is embedded below Recents; notifications and
|
||||
// announcements live in the avatar rail/dropdown.
|
||||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
||||
|
|
@ -328,13 +300,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
() =>
|
||||
(
|
||||
[
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox",
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Automations",
|
||||
url: `/dashboard/${workspaceId}/automations`,
|
||||
|
|
@ -358,8 +323,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[
|
||||
isInboxSidebarOpen,
|
||||
totalUnreadCount,
|
||||
workspaceId,
|
||||
isAutomationsActive,
|
||||
isArtifactsActive,
|
||||
|
|
@ -503,10 +466,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
if (item.url === "#inbox") {
|
||||
setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
|
||||
return;
|
||||
}
|
||||
// Desktop: Playground is a persistent toggle, not a plain link — it just
|
||||
// opens the second-level sidebar (which holds the whole API playground)
|
||||
// and only closes on a second click, never navigating away from the
|
||||
|
|
@ -518,7 +477,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
}
|
||||
router.push(item.url);
|
||||
},
|
||||
[router, setPlaygroundSidebarOpen, setActiveSlideoutPanel, isMobile]
|
||||
[router, setPlaygroundSidebarOpen, isMobile]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
|
|
@ -688,7 +647,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
||||
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
||||
const handleViewAllChats = useCallback(() => {
|
||||
setActiveSlideoutPanel(null);
|
||||
router.push(
|
||||
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
|
||||
);
|
||||
|
|
@ -764,10 +722,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
|||
: undefined
|
||||
}
|
||||
isLoadingChats={isLoadingThreads}
|
||||
activeSlideoutPanel={activeSlideoutPanel}
|
||||
onSlideoutPanelChange={setActiveSlideoutPanel}
|
||||
inbox={{
|
||||
isOpen: isInboxSidebarOpen,
|
||||
notifications={{
|
||||
totalUnreadCount,
|
||||
comments: {
|
||||
items: commentsInbox.inboxItems,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NavItem, User, Workspace } from "../../types/layout.types";
|
||||
import {
|
||||
NotificationsDropdown,
|
||||
type NotificationsDropdownData,
|
||||
} from "../sidebar/NotificationsDropdown";
|
||||
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
|
||||
import { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||
|
||||
|
|
@ -24,6 +28,7 @@ interface IconRailProps {
|
|||
onUserSettings?: () => void;
|
||||
onAnnouncements?: () => void;
|
||||
announcementUnreadCount?: number;
|
||||
notifications?: NotificationsDropdownData;
|
||||
onLogout?: () => void;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
|
|
@ -45,6 +50,7 @@ export function IconRail({
|
|||
onUserSettings,
|
||||
onAnnouncements,
|
||||
announcementUnreadCount = 0,
|
||||
notifications,
|
||||
onLogout,
|
||||
theme,
|
||||
setTheme,
|
||||
|
|
@ -145,6 +151,9 @@ export function IconRail({
|
|||
isCollapsed
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
topContent={
|
||||
notifications ? <NotificationsDropdown notifications={notifications} /> : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -27,14 +25,13 @@ import {
|
|||
RightPanelToggleButton,
|
||||
} from "../right-panel/RightPanel";
|
||||
import {
|
||||
InboxSidebarContent,
|
||||
MobileSidebar,
|
||||
MobileSidebarTrigger,
|
||||
PlaygroundSidebar,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
} from "../sidebar";
|
||||
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
||||
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
|
||||
import { TabBar } from "../tabs/TabBar";
|
||||
import { WorkspacePanel } from "./WorkspacePanel";
|
||||
|
||||
|
|
@ -80,28 +77,6 @@ function MacDesktopTitleBar({
|
|||
);
|
||||
}
|
||||
|
||||
// Per-tab data source
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type ActiveSlideoutPanel = "inbox" | null;
|
||||
|
||||
// Inbox-related props — per-tab data sources with independent loading/pagination
|
||||
interface InboxProps {
|
||||
isOpen: boolean;
|
||||
totalUnreadCount: number;
|
||||
comments: TabDataSource;
|
||||
status: TabDataSource;
|
||||
}
|
||||
|
||||
interface LayoutShellProps {
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
|
|
@ -140,11 +115,7 @@ interface LayoutShellProps {
|
|||
workspacePanelContentClassName?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
// Unified slide-out panel state
|
||||
activeSlideoutPanel?: ActiveSlideoutPanel;
|
||||
onSlideoutPanelChange?: (panel: ActiveSlideoutPanel) => void;
|
||||
// Inbox props
|
||||
inbox?: InboxProps;
|
||||
notifications?: NotificationsDropdownData;
|
||||
isLoadingChats?: boolean;
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onTabPrefetch?: (tab: Tab) => void;
|
||||
|
|
@ -245,9 +216,7 @@ export function LayoutShell({
|
|||
workspacePanelContentClassName,
|
||||
children,
|
||||
className,
|
||||
activeSlideoutPanel = null,
|
||||
onSlideoutPanelChange,
|
||||
inbox,
|
||||
notifications,
|
||||
isLoadingChats = false,
|
||||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
|
|
@ -269,17 +238,6 @@ export function LayoutShell({
|
|||
[isCollapsed, setIsCollapsed, toggleCollapsed]
|
||||
);
|
||||
|
||||
const closeSlideout = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onSlideoutPanelChange?.(null);
|
||||
},
|
||||
[onSlideoutPanelChange]
|
||||
);
|
||||
|
||||
const anySlideOutOpen = activeSlideoutPanel !== null;
|
||||
|
||||
const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel";
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
return (
|
||||
|
|
@ -316,6 +274,7 @@ export function LayoutShell({
|
|||
onUserSettings={onUserSettings}
|
||||
onAnnouncements={onAnnouncements}
|
||||
announcementUnreadCount={announcementUnreadCount}
|
||||
notifications={notifications}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
theme={theme}
|
||||
|
|
@ -335,34 +294,6 @@ export function LayoutShell({
|
|||
{children}
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* Mobile unified slide-out panel */}
|
||||
<SidebarSlideOutPanel
|
||||
open={anySlideOutOpen}
|
||||
onOpenChange={closeSlideout}
|
||||
ariaLabel={panelAriaLabel}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{activeSlideoutPanel === "inbox" && inbox && (
|
||||
<motion.div
|
||||
key="inbox"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<InboxSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SidebarSlideOutPanel>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
|
|
@ -400,6 +331,7 @@ export function LayoutShell({
|
|||
onUserSettings={onUserSettings}
|
||||
onAnnouncements={onAnnouncements}
|
||||
announcementUnreadCount={announcementUnreadCount}
|
||||
notifications={notifications}
|
||||
onLogout={onLogout}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
|
|
@ -425,10 +357,7 @@ export function LayoutShell({
|
|||
className={cn(
|
||||
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
|
||||
isMacDesktop
|
||||
? cn(
|
||||
"border-t border-r",
|
||||
!showPlaygroundSidebar && "rounded-tl-xl border-l"
|
||||
)
|
||||
? cn("border-t border-r", !showPlaygroundSidebar && "rounded-tl-xl border-l")
|
||||
: "border-r"
|
||||
)}
|
||||
>
|
||||
|
|
@ -491,33 +420,6 @@ export function LayoutShell({
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unified slide-out panel — shell stays open, content cross-fades */}
|
||||
<SidebarSlideOutPanel
|
||||
open={anySlideOutOpen}
|
||||
onOpenChange={closeSlideout}
|
||||
ariaLabel={panelAriaLabel}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{activeSlideoutPanel === "inbox" && inbox && (
|
||||
<motion.div
|
||||
key="inbox"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<InboxSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
comments={inbox.comments}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SidebarSlideOutPanel>
|
||||
</div>
|
||||
|
||||
<DesktopWorkspaceRegion>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
|
||||
import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar";
|
||||
import { NotificationsDropdown, type NotificationsDropdownData } from "./NotificationsDropdown";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { SidebarUserProfile } from "./SidebarUserProfile";
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ interface MobileSidebarProps {
|
|||
onUserSettings?: () => void;
|
||||
onAnnouncements?: () => void;
|
||||
announcementUnreadCount?: number;
|
||||
notifications?: NotificationsDropdownData;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
theme?: string;
|
||||
|
|
@ -83,6 +85,7 @@ export function MobileSidebar({
|
|||
onUserSettings,
|
||||
onAnnouncements,
|
||||
announcementUnreadCount = 0,
|
||||
notifications,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
theme,
|
||||
|
|
@ -161,6 +164,14 @@ export function MobileSidebar({
|
|||
isCollapsed
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
topContent={
|
||||
notifications ? (
|
||||
<NotificationsDropdown
|
||||
notifications={notifications}
|
||||
onCloseMobileSidebar={() => onOpenChange(false)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,378 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
isCommentReplyMetadata,
|
||||
isInsufficientCreditsMetadata,
|
||||
isNewMentionMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface NotificationsDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface NotificationsDropdownData {
|
||||
totalUnreadCount: number;
|
||||
comments: NotificationsDataSource;
|
||||
status: NotificationsDataSource;
|
||||
}
|
||||
|
||||
interface NotificationsDropdownProps {
|
||||
notifications: NotificationsDropdownData;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
type NotificationFilter = "mentions" | "status" | null;
|
||||
|
||||
function formatNotificationCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
}
|
||||
const thousands = Math.floor(count / 1000);
|
||||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
function formatTime(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return "now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return `${Math.floor(diffDays / 7)}w ago`;
|
||||
} catch {
|
||||
return "now";
|
||||
}
|
||||
}
|
||||
|
||||
function isCommentNotification(item: InboxItem): boolean {
|
||||
return item.type === "new_mention" || item.type === "comment_reply";
|
||||
}
|
||||
|
||||
export function NotificationsDropdown({
|
||||
notifications,
|
||||
onCloseMobileSidebar,
|
||||
}: NotificationsDropdownProps) {
|
||||
const router = useRouter();
|
||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<NotificationFilter>(null);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
const [markingAllAsRead, setMarkingAllAsRead] = useState(false);
|
||||
|
||||
const unreadLabel = formatNotificationCount(notifications.totalUnreadCount);
|
||||
const visibleUnreadCount =
|
||||
activeFilter === "mentions"
|
||||
? notifications.comments.unreadCount
|
||||
: activeFilter === "status"
|
||||
? notifications.status.unreadCount
|
||||
: notifications.totalUnreadCount;
|
||||
const visibleUnreadLabel = formatNotificationCount(visibleUnreadCount);
|
||||
const isLoading =
|
||||
activeFilter === "mentions"
|
||||
? notifications.comments.loading
|
||||
: activeFilter === "status"
|
||||
? notifications.status.loading
|
||||
: notifications.comments.loading || notifications.status.loading;
|
||||
const items = useMemo(() => {
|
||||
const sourceItems =
|
||||
activeFilter === "mentions"
|
||||
? notifications.comments.items
|
||||
: activeFilter === "status"
|
||||
? notifications.status.items
|
||||
: [...notifications.comments.items, ...notifications.status.items];
|
||||
|
||||
return sourceItems
|
||||
.toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 8);
|
||||
}, [activeFilter, notifications.comments.items, notifications.status.items]);
|
||||
|
||||
const markItemAsRead = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
if (item.read) return;
|
||||
setMarkingAsReadId(item.id);
|
||||
try {
|
||||
await (isCommentNotification(item)
|
||||
? notifications.comments.markAsRead(item.id)
|
||||
: notifications.status.markAsRead(item.id));
|
||||
} finally {
|
||||
setMarkingAsReadId(null);
|
||||
}
|
||||
},
|
||||
[notifications.comments, notifications.status]
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
await markItemAsRead(item);
|
||||
|
||||
if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) {
|
||||
const threadId = item.metadata.thread_id;
|
||||
const commentId = item.metadata.comment_id;
|
||||
if (item.workspace_id && threadId) {
|
||||
if (commentId) setTargetCommentId(commentId);
|
||||
setOpen(false);
|
||||
onCloseMobileSidebar?.();
|
||||
router.push(
|
||||
commentId
|
||||
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}`
|
||||
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) {
|
||||
const threadId = item.metadata.thread_id;
|
||||
const replyId = item.metadata.reply_id;
|
||||
if (item.workspace_id && threadId) {
|
||||
if (replyId) setTargetCommentId(replyId);
|
||||
setOpen(false);
|
||||
onCloseMobileSidebar?.();
|
||||
router.push(
|
||||
replyId
|
||||
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}`
|
||||
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) {
|
||||
if (item.metadata.action_url) {
|
||||
setOpen(false);
|
||||
onCloseMobileSidebar?.();
|
||||
router.push(item.metadata.action_url);
|
||||
}
|
||||
}
|
||||
},
|
||||
[markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
if (visibleUnreadCount === 0 || markingAllAsRead) return;
|
||||
setMarkingAllAsRead(true);
|
||||
try {
|
||||
if (activeFilter === "mentions") {
|
||||
await notifications.comments.markAllAsRead();
|
||||
} else if (activeFilter === "status") {
|
||||
await notifications.status.markAllAsRead();
|
||||
} else {
|
||||
await Promise.all([
|
||||
notifications.comments.markAllAsRead(),
|
||||
notifications.status.markAllAsRead(),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
setMarkingAllAsRead(false);
|
||||
}
|
||||
}, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]);
|
||||
|
||||
const handleFilterClick = useCallback((filter: Exclude<NotificationFilter, null>) => {
|
||||
setActiveFilter((current) => (current === filter ? null : filter));
|
||||
}, []);
|
||||
|
||||
const emptyStateCopy =
|
||||
activeFilter === "mentions"
|
||||
? {
|
||||
title: "No mentions",
|
||||
description: "Mentions and replies will appear here.",
|
||||
}
|
||||
: activeFilter === "status"
|
||||
? {
|
||||
title: "No status updates",
|
||||
description: "Connector and document updates will appear here.",
|
||||
}
|
||||
: {
|
||||
title: "No notifications",
|
||||
description: "Mentions, replies, and status updates will appear here.",
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={
|
||||
notifications.totalUnreadCount > 0
|
||||
? `Notifications, ${unreadLabel} unread`
|
||||
: "Notifications"
|
||||
}
|
||||
className={cn(
|
||||
"relative h-10 w-10 rounded-lg text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
open && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{notifications.totalUnreadCount > 0 ? (
|
||||
<span className="absolute right-1 top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground">
|
||||
{unreadLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Notifications
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="z-80 flex max-h-[min(520px,calc(100vh-2rem))] w-[360px] flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold">Notifications</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{visibleUnreadCount > 0 ? `${visibleUnreadLabel} unread` : "You're all caught up"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={visibleUnreadCount === 0 || markingAllAsRead}
|
||||
className="h-8 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
{markingAllAsRead ? <Spinner size="xs" /> : null}
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={activeFilter === "mentions"}
|
||||
onClick={() => handleFilterClick("mentions")}
|
||||
className={cn(
|
||||
"h-7 rounded-full px-2.5 text-xs text-muted-foreground",
|
||||
activeFilter === "mentions" && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
Mentions
|
||||
{notifications.comments.unreadCount > 0 ? (
|
||||
<span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/15 px-1 text-[10px] font-medium text-primary">
|
||||
{formatNotificationCount(notifications.comments.unreadCount)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={activeFilter === "status"}
|
||||
onClick={() => handleFilterClick("status")}
|
||||
className={cn(
|
||||
"h-7 rounded-full px-2.5 text-xs text-muted-foreground",
|
||||
activeFilter === "status" && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
Status
|
||||
{notifications.status.unreadCount > 0 ? (
|
||||
<span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/15 px-1 text-[10px] font-medium text-primary">
|
||||
{formatNotificationCount(notifications.status.unreadCount)}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{isLoading ? (
|
||||
<div className="space-y-1">
|
||||
{[82, 64, 74].map((width) => (
|
||||
<div key={width} className="flex h-[72px] items-center rounded-lg px-2 py-2">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-3 rounded" style={{ width: `${width}%` }} />
|
||||
<Skeleton className="h-2.5 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : items.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isMarkingAsRead}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className={cn(
|
||||
"group h-auto w-full justify-start rounded-lg px-2 py-2 text-left",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
!item.read && "bg-accent/40"
|
||||
)}
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "0 72px" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"line-clamp-1 flex-1 text-sm font-medium",
|
||||
!item.read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
{!item.read ? (
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs font-normal text-muted-foreground group-hover:text-accent-foreground/80">
|
||||
{item.message}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] font-normal text-muted-foreground/70">
|
||||
{formatTime(item.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{isMarkingAsRead ? <Spinner size="xs" className="shrink-0" /> : null}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<p className="text-sm font-medium">{emptyStateCopy.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,21 +46,6 @@ function ChatListSkeletonRows() {
|
|||
);
|
||||
}
|
||||
|
||||
function CollapsedInboxIcon({ item }: { item: NavItem }) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{typeof item.badge === "string" ? (
|
||||
<span className="absolute right-0 top-0 flex min-w-3.5 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-medium leading-3 text-destructive-foreground">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
workspace: Workspace | null;
|
||||
isCollapsed?: boolean;
|
||||
|
|
@ -138,10 +123,9 @@ export function Sidebar({
|
|||
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
|
||||
const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false);
|
||||
|
||||
// Inbox, Automations, and Artifacts are rendered explicitly right below
|
||||
// Automations, Artifacts, and Playground are rendered explicitly right below
|
||||
// New Chat. Pull them out of the nav items list so they don't also appear
|
||||
// in the bottom NavSection. Documents is embedded below Recents.
|
||||
const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
|
||||
const automationsItem = useMemo(
|
||||
() => navItems.find((item) => item.url.endsWith("/automations")),
|
||||
[navItems]
|
||||
|
|
@ -158,7 +142,6 @@ export function Sidebar({
|
|||
() =>
|
||||
navItems.filter(
|
||||
(item) =>
|
||||
item.url !== "#inbox" &&
|
||||
!item.url.endsWith("/automations") &&
|
||||
!item.url.endsWith("/artifacts") &&
|
||||
!item.url.endsWith("/playground")
|
||||
|
|
@ -235,23 +218,6 @@ export function Sidebar({
|
|||
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 pt-0.5 pb-1.5">
|
||||
{inboxItem && (
|
||||
<SidebarButton
|
||||
icon={inboxItem.icon}
|
||||
label={inboxItem.title}
|
||||
onClick={() => onNavItemClick?.(inboxItem)}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={inboxItem.isActive}
|
||||
badge={inboxItem.badge}
|
||||
collapsedIconNode={<CollapsedInboxIcon item={inboxItem} />}
|
||||
tooltipContent={isCollapsed ? inboxItem.title : undefined}
|
||||
buttonProps={
|
||||
{
|
||||
"data-joyride": "inbox-sidebar",
|
||||
} as React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{automationsItem && (
|
||||
<SidebarButton
|
||||
icon={automationsItem.icon}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -72,6 +73,7 @@ interface SidebarUserProfileProps {
|
|||
isCollapsed?: boolean;
|
||||
theme?: string;
|
||||
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||
topContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
function formatAnnouncementCount(count: number): string {
|
||||
|
|
@ -134,6 +136,7 @@ export function SidebarUserProfile({
|
|||
isCollapsed = false,
|
||||
theme,
|
||||
setTheme,
|
||||
topContent,
|
||||
}: SidebarUserProfileProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const { locale, setLocale } = useLocaleContext();
|
||||
|
|
@ -171,6 +174,7 @@ export function SidebarUserProfile({
|
|||
return (
|
||||
<div className="w-full border-t px-1.5 py-2">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{topContent}
|
||||
{showDownloadCta && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ export { AllChatsWorkspaceContent } from "./AllChatsSidebar";
|
|||
export { ChatListItem } from "./ChatListItem";
|
||||
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { NotificationsDropdown } from "./NotificationsDropdown";
|
||||
export { PlaygroundSidebar } from "./PlaygroundSidebar";
|
||||
export { Sidebar } from "./Sidebar";
|
||||
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue