SurfSense/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
CREDO23 7bc52dcdc0 feat(web): surface Automations in the sidebar under Inbox
Adds an "Automations" nav entry rendered explicitly between Inbox and
(on mobile) Documents, mirroring how those two are pulled out of the
nav list and rendered above the chat sections. The icon is Workflow
to match settings/RBAC labelling.

LayoutDataProvider:
- Adds the entry to navItems pointing at /dashboard/[id]/automations.
- Marks isActive via pathname so the row highlights on the route.
- Tags /automations as a workspace-panel page so it renders in the
  centered settings-style viewport (same chrome as Team / settings).

Sidebar:
- Pulls out automationsItem alongside inboxItem and documentsItem.
- Renders it between them.
- Excludes its URL from footerNavItems so it doesn't double-render.

Page-level RBAC still gates the actual view; the sidebar entry is
always visible (consistent with Inbox/Documents which are also not
gated at the nav layer).

Anonymous (FreeLayoutDataProvider) intentionally not touched —
automations is an authenticated feature.
2026-05-28 01:11:20 +02:00

496 lines
16 KiB
TypeScript

"use client";
import { CreditCard, Dot, SquarePen, Zap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { type ReactNode, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { cn } from "@/lib/utils";
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { AuthenticatedPageUsageDisplay } from "./AuthenticatedPageUsageDisplay";
import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection";
import { PremiumTokenUsageDisplay } from "./PremiumTokenUsageDisplay";
import { SidebarButton } from "./SidebarButton";
import { SidebarCollapseButton } from "./SidebarCollapseButton";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarSection } from "./SidebarSection";
import { SidebarUserProfile } from "./SidebarUserProfile";
const CHAT_LIST_SKELETON_WIDTHS = ["w-[78%]", "w-[64%]", "w-[86%]", "w-[58%]", "w-[72%]"];
function ChatListItemSkeleton({ widthClass }: { widthClass: string }) {
return (
<div className="group/item relative w-full">
<div className="flex h-[32px] w-full items-center rounded-md px-2 py-1.5">
<Skeleton className={cn("h-4 rounded", widthClass)} />
</div>
</div>
);
}
function ChatListSkeletonRows() {
return (
<div className="flex flex-col gap-0.5">
{CHAT_LIST_SKELETON_WIDTHS.map((widthClass) => (
<ChatListItemSkeleton key={widthClass} widthClass={widthClass} />
))}
</div>
);
}
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 {
searchSpace: SearchSpace | null;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
sharedChats?: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatRename?: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void;
isSharedChatsPanelOpen?: boolean;
isPrivateChatsPanelOpen?: boolean;
user: User;
onSettings?: () => void;
onManageMembers?: () => void;
onUserSettings?: () => void;
onAnnouncements?: () => void;
onNavigate?: () => void;
announcementUnreadCount?: number;
onLogout?: () => void;
pageUsage?: PageUsage;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
className?: string;
isLoadingChats?: boolean;
disableTooltips?: boolean;
sidebarWidth?: number;
isResizing?: boolean;
renderUserProfile?: boolean;
renderCollapseButton?: boolean;
collapsedHeaderContent?: ReactNode;
}
export function Sidebar({
searchSpace,
isCollapsed = false,
onToggleCollapse,
navItems,
onNavItemClick,
chats,
sharedChats = [],
activeChatId,
onNewChat,
onChatSelect,
onChatRename,
onChatDelete,
onChatArchive,
onViewAllSharedChats,
onViewAllPrivateChats,
isSharedChatsPanelOpen = false,
isPrivateChatsPanelOpen = false,
user,
onSettings,
onManageMembers,
onUserSettings,
onAnnouncements,
onNavigate,
announcementUnreadCount = 0,
onLogout,
pageUsage,
theme,
setTheme,
className,
isLoadingChats = false,
disableTooltips = false,
sidebarWidth = SIDEBAR_MIN_WIDTH,
isResizing = false,
renderUserProfile = true,
renderCollapseButton = true,
collapsedHeaderContent,
}: SidebarProps) {
const t = useTranslations("sidebar");
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
// Inbox, Automations, and Documents 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 only present in navItems on
// mobile; Automations is identified by URL suffix so the same code path
// works across search spaces.
const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
const automationsItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/automations")),
[navItems]
);
const documentsItem = useMemo(
() => navItems.find((item) => item.url === "#documents"),
[navItems]
);
const footerNavItems = useMemo(
() =>
navItems.filter(
(item) =>
item.url !== "#inbox" && item.url !== "#documents" && !item.url.endsWith("/automations")
),
[navItems]
);
const collapsedWidth = 51;
return (
<div
className={cn(
"relative flex h-full flex-col bg-panel text-sidebar-foreground overflow-hidden select-none",
!isResizing && "transition-[width] duration-200 ease-out",
className
)}
style={{ width: isCollapsed ? collapsedWidth : sidebarWidth }}
>
<div className="relative flex h-12 shrink-0 items-center gap-0 px-1 border-b">
<div
className={cn(
"min-w-0 overflow-hidden",
"transition-[max-width,opacity,margin-right] duration-200 ease-out",
isCollapsed ? "max-w-0 opacity-0 mr-0" : "max-w-[400px] flex-1 opacity-100"
)}
aria-hidden={isCollapsed}
>
<SidebarHeader
searchSpace={searchSpace}
isCollapsed={false}
onSettings={onSettings}
onManageMembers={onManageMembers}
/>
</div>
{collapsedHeaderContent ? (
<div
aria-hidden={!isCollapsed}
className={cn(
"pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center transition-opacity duration-150",
isCollapsed ? "opacity-100 delay-150" : "opacity-0"
)}
style={{ width: collapsedWidth }}
>
{collapsedHeaderContent}
</div>
) : null}
{renderCollapseButton ? (
<div className={cn("shrink-0", isCollapsed && "mx-auto")}>
<SidebarCollapseButton
isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})}
disableTooltip={disableTooltips}
/>
</div>
) : null}
</div>
<div className="flex flex-col gap-0.5 py-1.5">
<SidebarButton
icon={SquarePen}
label={t("new_chat")}
onClick={onNewChat}
isCollapsed={isCollapsed}
/>
{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}
label={automationsItem.title}
onClick={() => onNavItemClick?.(automationsItem)}
isCollapsed={isCollapsed}
isActive={automationsItem.isActive}
tooltipContent={isCollapsed ? automationsItem.title : undefined}
/>
)}
{documentsItem && (
<SidebarButton
icon={documentsItem.icon}
label={documentsItem.title}
onClick={() => onNavItemClick?.(documentsItem)}
isCollapsed={isCollapsed}
isActive={documentsItem.isActive}
tooltipContent={isCollapsed ? documentsItem.title : undefined}
/>
)}
</div>
{/* Chat sections - fills available space */}
{isCollapsed ? (
<div className="flex-1 w-full" />
) : (
<div className="flex-1 flex flex-col gap-1 pt-2 w-full min-h-0 overflow-hidden">
{/* Shared Chats Section - takes only space needed, max 50% */}
<SidebarSection
title={t("shared_chats")}
defaultOpen={true}
fillHeight={false}
className="shrink-0 max-h-[50%] flex flex-col"
alwaysShowAction={!disableTooltips && isSharedChatsPanelOpen}
action={
onViewAllSharedChats ? (
<Button
type="button"
variant="ghost"
onClick={onViewAllSharedChats}
className="h-auto cursor-pointer whitespace-nowrap bg-transparent p-0 text-xs font-medium text-muted-foreground/60 transition-colors hover:bg-transparent hover:text-muted-foreground"
>
{!disableTooltips && isSharedChatsPanelOpen ? t("hide") : t("show_all")}
</Button>
) : undefined
}
>
{isLoadingChats ? (
<ChatListSkeletonRows />
) : sharedChats.length > 0 ? (
<div className="relative min-h-0 flex-1">
<div
className={`flex flex-col gap-0.5 max-h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${sharedChats.length > 4 ? "pb-2" : ""}`}
>
{sharedChats.slice(0, 20).map((chat) => (
<ChatListItem
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
dropdownOpen={openDropdownChatId === chat.id}
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
</div>
{/* Gradient fade indicator when more than 4 items */}
{sharedChats.length > 4 && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-sidebar/80 to-transparent" />
)}
</div>
) : (
<p className="px-2 py-1 text-sm text-muted-foreground/60">{t("no_shared_chats")}</p>
)}
</SidebarSection>
{/* Private Chats Section - fills remaining space */}
<SidebarSection
title={t("chats")}
defaultOpen={true}
fillHeight={true}
alwaysShowAction={!disableTooltips && isPrivateChatsPanelOpen}
action={
onViewAllPrivateChats ? (
<Button
type="button"
variant="ghost"
onClick={onViewAllPrivateChats}
className="h-auto cursor-pointer whitespace-nowrap bg-transparent p-0 text-xs font-medium text-muted-foreground/60 transition-colors hover:bg-transparent hover:text-muted-foreground"
>
{!disableTooltips && isPrivateChatsPanelOpen ? t("hide") : t("show_all")}
</Button>
) : undefined
}
>
{isLoadingChats ? (
<ChatListSkeletonRows />
) : chats.length > 0 ? (
<div className="relative flex-1 min-h-0">
<div
className={`flex flex-col gap-0.5 h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${chats.length > 4 ? "pb-2" : ""}`}
>
{chats.slice(0, 20).map((chat) => (
<ChatListItem
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
archived={chat.archived}
dropdownOpen={openDropdownChatId === chat.id}
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}
onClick={() => onChatSelect(chat)}
onRename={() => onChatRename?.(chat)}
onArchive={() => onChatArchive?.(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
</div>
{/* Gradient fade indicator when more than 4 items */}
{chats.length > 4 && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-sidebar/80 to-transparent" />
)}
</div>
) : (
<p className="px-2 py-1 text-sm text-muted-foreground/60">{t("no_chats")}</p>
)}
</SidebarSection>
</div>
)}
{/* Footer */}
<div className="mt-auto border-t">
{/* Platform navigation */}
{footerNavItems.length > 0 && (
<NavSection
items={footerNavItems}
onItemClick={onNavItemClick}
isCollapsed={isCollapsed}
/>
)}
<SidebarUsageFooter
pageUsage={pageUsage}
isCollapsed={isCollapsed}
hasNavSectionAbove={footerNavItems.length > 0}
onNavigate={onNavigate}
/>
{renderUserProfile && (
<SidebarUserProfile
user={user}
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
onLogout={onLogout}
isCollapsed={isCollapsed}
theme={theme}
setTheme={setTheme}
/>
)}
</div>
</div>
);
}
function SidebarUsageFooter({
pageUsage,
isCollapsed,
hasNavSectionAbove = false,
onNavigate,
}: {
pageUsage?: PageUsage;
isCollapsed: boolean;
hasNavSectionAbove?: boolean;
onNavigate?: () => void;
}) {
const params = useParams();
const searchSpaceId = params?.search_space_id ?? "";
const isAnonymous = useIsAnonymous();
if (isCollapsed) return null;
const containerClass = cn("px-3 py-3 space-y-3", hasNavSectionAbove && "border-t");
if (isAnonymous) {
return (
<div className={containerClass}>
{pageUsage && (
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{pageUsage.pagesUsed.toLocaleString()} / {pageUsage.pagesLimit.toLocaleString()}{" "}
tokens
</span>
<span className="font-medium">
{Math.min(
(pageUsage.pagesUsed / Math.max(pageUsage.pagesLimit, 1)) * 100,
100
).toFixed(0)}
%
</span>
</div>
<Progress
value={Math.min((pageUsage.pagesUsed / Math.max(pageUsage.pagesLimit, 1)) * 100, 100)}
className="h-1.5"
/>
</div>
)}
<Link
href="/register"
className="flex items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Create Free Account
</Link>
</div>
);
}
return (
<div className={containerClass}>
<PremiumTokenUsageDisplay />
<AuthenticatedPageUsageDisplay />
<div className="space-y-0.5">
<Link
href={`/dashboard/${searchSpaceId}/more-pages`}
onClick={onNavigate}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" />
Get Free Pages
</span>
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE
</Badge>
</Link>
<Link
href={`/dashboard/${searchSpaceId}/buy-more`}
onClick={onNavigate}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<CreditCard className="h-3 w-3 shrink-0" />
Buy More
</span>
<span className="flex items-center text-[10px] font-medium text-muted-foreground">
$1/1k
<Dot className="h-3 w-3" />
$1/1M
</span>
</Link>
</div>
</div>
);
}