mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
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.
496 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|