-
-
Selected Files ({files.length})
-
+
+
+
-
-
- {files.map((file, index) => (
-
-
-
- {file.name}
-
+ {/* File List Card */}
+
+ {files.length > 0 && (
+
+
+
+
+
+ Selected Files ({files.length})
+
+ Total size: {formatFileSize(getTotalFileSize())}
+
+
+
setFiles([])}
+ disabled={isUploading}
+ >
+ Clear all
+
+
+
+
+
+
+ {files.map((file, index) => (
+
+
+
+
+
+
+
{file.name}
+
+
+ {formatFileSize(file.size)}
+
+
+ {file.type || "Unknown type"}
+
+
+
+
-
- {formatFileSize(file.size)}
-
removeFile(index)}
disabled={isUploading}
className="h-8 w-8"
- aria-label={`Remove ${file.name}`}
>
-
-
-
-
-
- {file.type || "Unknown type"}
-
-
-
-
- modified {new Date(file.lastModified).toLocaleDateString()}
-
-
-
- ))}
-
-
-
-
-
- {isUploading ? (
-
-
-
- Uploading...
-
- ) : (
-
-
-
- Upload {files.length} {files.length === 1 ? "file" : "files"}
-
-
- )}
-
-
-
- )}
-
+ ))}
+
+
- {/* File type information */}
-
-
-
-
-
Supported file types:
-
+ {isUploading && (
+
+
+
+
+ Uploading files...
+ {Math.round(uploadProgress)}%
+
+
+
+
+ )}
+
+
+
+ {isUploading ? (
+
+
+
+
+ Uploading...
+
+ ) : (
+
+
+
+ Upload {files.length} {files.length === 1 ? "file" : "files"}
+
+
+ )}
+
+
+
+
+
+ )}
+
+
+ {/* Supported File Types Card */}
+
+
+
+
+
+ Supported File Types
+
+
+ These file types are supported based on your current ETL service configuration.
+
+
+
{supportedExtensions.map((ext) => (
-
+
{ext}
-
+
))}
-
-
+
+
diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
index 9dc18621b..d0e04fe68 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx
@@ -31,7 +31,6 @@ export default function DashboardLayout({
title: "Researcher",
url: `/dashboard/${search_space_id}/researcher`,
icon: "SquareTerminal",
- isActive: true,
items: [],
},
diff --git a/surfsense_web/app/dashboard/[search_space_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/page.tsx
new file mode 100644
index 000000000..cd697db21
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/page.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { useParams, useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+export default function SearchSpaceDashboardPage() {
+ const router = useRouter();
+ const { search_space_id } = useParams();
+
+ useEffect(() => {
+ router.push(`/dashboard/${search_space_id}/chats`);
+ }, []);
+
+ return <>>;
+}
diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx
new file mode 100644
index 000000000..1bc7abd2a
--- /dev/null
+++ b/surfsense_web/components/dashboard-breadcrumb.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import React from "react";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+
+interface BreadcrumbItemInterface {
+ label: string;
+ href?: string;
+}
+
+export function DashboardBreadcrumb() {
+ const pathname = usePathname();
+
+ // Parse the pathname to create breadcrumb items
+ const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
+ const segments = path.split("/").filter(Boolean);
+ const breadcrumbs: BreadcrumbItemInterface[] = [];
+
+ // Always start with Dashboard
+ breadcrumbs.push({ label: "Dashboard", href: "/dashboard" });
+
+ // Handle search space
+ if (segments[0] === "dashboard" && segments[1]) {
+ breadcrumbs.push({ label: `Search Space ${segments[1]}`, href: `/dashboard/${segments[1]}` });
+
+ // Handle specific sections
+ if (segments[2]) {
+ const section = segments[2];
+ let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
+
+ // Map section names to more readable labels
+ const sectionLabels: Record
= {
+ researcher: "Researcher",
+ documents: "Documents",
+ connectors: "Connectors",
+ podcasts: "Podcasts",
+ logs: "Logs",
+ chats: "Chats",
+ };
+
+ sectionLabel = sectionLabels[section] || sectionLabel;
+
+ // Handle sub-sections
+ if (segments[3]) {
+ const subSection = segments[3];
+ let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
+
+ // Handle documents sub-sections
+ if (section === "documents") {
+ const documentLabels: Record = {
+ upload: "Upload Documents",
+ youtube: "Add YouTube Videos",
+ webpage: "Add Webpages",
+ };
+
+ const documentLabel = documentLabels[subSection] || subSectionLabel;
+ breadcrumbs.push({
+ label: "Documents",
+ href: `/dashboard/${segments[1]}/documents`,
+ });
+ breadcrumbs.push({ label: documentLabel });
+ return breadcrumbs;
+ }
+
+ // Handle connector sub-sections
+ if (section === "connectors") {
+ // Handle specific connector types
+ if (subSection === "add" && segments[4]) {
+ const connectorType = segments[4];
+ const connectorLabels: Record = {
+ "github-connector": "GitHub",
+ "jira-connector": "Jira",
+ "confluence-connector": "Confluence",
+ "discord-connector": "Discord",
+ "linear-connector": "Linear",
+ "clickup-connector": "ClickUp",
+ "slack-connector": "Slack",
+ "notion-connector": "Notion",
+ "tavily-api": "Tavily API",
+ "serper-api": "Serper API",
+ "linkup-api": "LinkUp API",
+ };
+
+ const connectorLabel = connectorLabels[connectorType] || connectorType;
+ breadcrumbs.push({
+ label: "Connectors",
+ href: `/dashboard/${segments[1]}/connectors`,
+ });
+ breadcrumbs.push({
+ label: "Add Connector",
+ href: `/dashboard/${segments[1]}/connectors/add`,
+ });
+ breadcrumbs.push({ label: connectorLabel });
+ return breadcrumbs;
+ }
+
+ const connectorLabels: Record = {
+ add: "Add Connector",
+ manage: "Manage Connectors",
+ };
+
+ const connectorLabel = connectorLabels[subSection] || subSectionLabel;
+ breadcrumbs.push({
+ label: "Connectors",
+ href: `/dashboard/${segments[1]}/connectors`,
+ });
+ breadcrumbs.push({ label: connectorLabel });
+ return breadcrumbs;
+ }
+
+ // Handle other sub-sections
+ const subSectionLabels: Record = {
+ upload: "Upload Documents",
+ youtube: "Add YouTube Videos",
+ webpage: "Add Webpages",
+ add: "Add Connector",
+ edit: "Edit Connector",
+ manage: "Manage",
+ };
+
+ subSectionLabel = subSectionLabels[subSection] || subSectionLabel;
+
+ breadcrumbs.push({
+ label: sectionLabel,
+ href: `/dashboard/${segments[1]}/${section}`,
+ });
+ breadcrumbs.push({ label: subSectionLabel });
+ } else {
+ breadcrumbs.push({ label: sectionLabel });
+ }
+ }
+ }
+
+ return breadcrumbs;
+ };
+
+ const breadcrumbs = generateBreadcrumbs(pathname);
+
+ if (breadcrumbs.length <= 1) {
+ return null; // Don't show breadcrumbs for root dashboard
+ }
+
+ return (
+
+
+ {breadcrumbs.map((item, index) => (
+
+
+ {index === breadcrumbs.length - 1 ? (
+ {item.label}
+ ) : (
+ {item.label}
+ )}
+
+ {index < breadcrumbs.length - 1 && }
+
+ ))}
+
+
+ );
+}
diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
index 1a5920aea..684e04653 100644
--- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx
+++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
@@ -1,7 +1,7 @@
"use client";
import { Trash2 } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button";
import {
@@ -12,7 +12,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { apiClient } from "@/lib/api"; // Import the API client
+import { apiClient } from "@/lib/api";
interface Chat {
created_at: string;
@@ -80,66 +80,82 @@ export function AppSidebarProvider({
setIsClient(true);
}, []);
+ // Memoized fetch function for chats
+ const fetchRecentChats = useCallback(async () => {
+ try {
+ // Only run on client-side
+ if (typeof window === "undefined") return;
+
+ const chats: Chat[] = await apiClient.get(
+ `api/v1/chats/?limit=5&skip=0&search_space_id=${searchSpaceId}`
+ );
+
+ // Sort chats by created_at in descending order (newest first)
+ const sortedChats = chats.sort(
+ (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ );
+
+ // Transform API response to the format expected by AppSidebar
+ const formattedChats = sortedChats.map((chat) => ({
+ name: chat.title || `Chat ${chat.id}`,
+ url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
+ icon: "MessageCircleMore",
+ id: chat.id,
+ search_space_id: chat.search_space_id,
+ actions: [
+ {
+ name: "Delete",
+ icon: "Trash2",
+ onClick: () => {
+ setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
+ setShowDeleteDialog(true);
+ },
+ },
+ ],
+ }));
+
+ setRecentChats(formattedChats);
+ setChatError(null);
+ } catch (error) {
+ console.error("Error fetching chats:", error);
+ setChatError(error instanceof Error ? error.message : "Unknown error occurred");
+ setRecentChats([]);
+ } finally {
+ setIsLoadingChats(false);
+ }
+ }, [searchSpaceId]);
+
+ // Memoized fetch function for search space
+ const fetchSearchSpace = useCallback(async () => {
+ try {
+ // Only run on client-side
+ if (typeof window === "undefined") return;
+
+ const data: SearchSpace = await apiClient.get(
+ `api/v1/searchspaces/${searchSpaceId}`
+ );
+ setSearchSpace(data);
+ setSearchSpaceError(null);
+ } catch (error) {
+ console.error("Error fetching search space:", error);
+ setSearchSpaceError(error instanceof Error ? error.message : "Unknown error occurred");
+ } finally {
+ setIsLoadingSearchSpace(false);
+ }
+ }, [searchSpaceId]);
+
+ // Retry function
+ const retryFetch = useCallback(() => {
+ setChatError(null);
+ setSearchSpaceError(null);
+ setIsLoadingChats(true);
+ setIsLoadingSearchSpace(true);
+ fetchRecentChats();
+ fetchSearchSpace();
+ }, [fetchRecentChats, fetchSearchSpace]);
+
// Fetch recent chats
useEffect(() => {
- const fetchRecentChats = async () => {
- try {
- // Only run on client-side
- if (typeof window === "undefined") return;
-
- try {
- // Use the API client instead of direct fetch - filter by current search space ID
- const chats: Chat[] = await apiClient.get(
- `api/v1/chats/?limit=5&skip=0&search_space_id=${searchSpaceId}`
- );
-
- // Sort chats by created_at in descending order (newest first)
- const sortedChats = chats.sort(
- (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
- );
- // console.log("sortedChats", sortedChats);
- // Transform API response to the format expected by AppSidebar
- const formattedChats = sortedChats.map((chat) => ({
- name: chat.title || `Chat ${chat.id}`, // Fallback if title is empty
- url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
- icon: "MessageCircleMore",
- id: chat.id,
- search_space_id: chat.search_space_id,
- actions: [
- {
- name: "View Details",
- icon: "ExternalLink",
- onClick: () => {
- window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`;
- },
- },
- {
- name: "Delete",
- icon: "Trash2",
- onClick: () => {
- setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
- setShowDeleteDialog(true);
- },
- },
- ],
- }));
-
- setRecentChats(formattedChats);
- setChatError(null);
- } catch (error) {
- console.error("Error fetching chats:", error);
- setChatError(error instanceof Error ? error.message : "Unknown error occurred");
- // Provide empty array to ensure UI still renders
- setRecentChats([]);
- } finally {
- setIsLoadingChats(false);
- }
- } catch (error) {
- console.error("Error in fetchRecentChats:", error);
- setIsLoadingChats(false);
- }
- };
-
fetchRecentChats();
// Set up a refresh interval (every 5 minutes)
@@ -147,144 +163,144 @@ export function AppSidebarProvider({
// Clean up interval on component unmount
return () => clearInterval(intervalId);
- }, [searchSpaceId]);
+ }, [fetchRecentChats]);
- // Handle delete chat
- const handleDeleteChat = async () => {
+ // Fetch search space details
+ useEffect(() => {
+ fetchSearchSpace();
+ }, [fetchSearchSpace]);
+
+ // Handle delete chat with better error handling
+ const handleDeleteChat = useCallback(async () => {
if (!chatToDelete) return;
try {
setIsDeleting(true);
- // Use the API client instead of direct fetch
await apiClient.delete(`api/v1/chats/${chatToDelete.id}`);
- // Close dialog and refresh chats
- setRecentChats(recentChats.filter((chat) => chat.id !== chatToDelete.id));
+ // Update local state
+ setRecentChats((prev) => prev.filter((chat) => chat.id !== chatToDelete.id));
} catch (error) {
console.error("Error deleting chat:", error);
+ // You could show a toast notification here
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
setChatToDelete(null);
}
- };
+ }, [chatToDelete]);
- // Fetch search space details
- useEffect(() => {
- const fetchSearchSpace = async () => {
- try {
- // Only run on client-side
- if (typeof window === "undefined") return;
+ // Memoized fallback chats
+ const fallbackChats = useMemo(() => {
+ if (chatError) {
+ return [
+ {
+ name: "Error loading chats",
+ url: "#",
+ icon: "AlertCircle",
+ id: 0,
+ search_space_id: Number(searchSpaceId),
+ actions: [
+ {
+ name: "Retry",
+ icon: "RefreshCw",
+ onClick: retryFetch,
+ },
+ ],
+ },
+ ];
+ }
- try {
- // Use the API client instead of direct fetch
- const data: SearchSpace = await apiClient.get(
- `api/v1/searchspaces/${searchSpaceId}`
- );
- setSearchSpace(data);
- setSearchSpaceError(null);
- } catch (error) {
- console.error("Error fetching search space:", error);
- setSearchSpaceError(error instanceof Error ? error.message : "Unknown error occurred");
- } finally {
- setIsLoadingSearchSpace(false);
- }
- } catch (error) {
- console.error("Error in fetchSearchSpace:", error);
- setIsLoadingSearchSpace(false);
- }
- };
+ if (!isLoadingChats && recentChats.length === 0) {
+ return [
+ {
+ name: "No recent chats",
+ url: "#",
+ icon: "MessageCircleMore",
+ id: 0,
+ search_space_id: Number(searchSpaceId),
+ actions: [],
+ },
+ ];
+ }
- fetchSearchSpace();
- }, [searchSpaceId]);
-
- // Create a fallback chat if there's an error or no chats
- const fallbackChats =
- chatError || (!isLoadingChats && recentChats.length === 0)
- ? [
- {
- name: chatError ? "Error loading chats" : "No recent chats",
- url: "#",
- icon: chatError ? "AlertCircle" : "MessageCircleMore",
- id: 0,
- search_space_id: Number(searchSpaceId),
- actions: [],
- },
- ]
- : [];
+ return [];
+ }, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch]);
// Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
- // Update the first item in navSecondary to show the search space name
- const updatedNavSecondary = [...navSecondary];
- if (updatedNavSecondary.length > 0 && isClient) {
- updatedNavSecondary[0] = {
- ...updatedNavSecondary[0],
- title:
- searchSpace?.name ||
- (isLoadingSearchSpace
- ? "Loading..."
- : searchSpaceError
- ? "Error loading search space"
- : "Unknown Search Space"),
- };
+ // Memoized updated navSecondary
+ const updatedNavSecondary = useMemo(() => {
+ const updated = [...navSecondary];
+ if (updated.length > 0 && isClient) {
+ updated[0] = {
+ ...updated[0],
+ title:
+ searchSpace?.name ||
+ (isLoadingSearchSpace
+ ? "Loading..."
+ : searchSpaceError
+ ? "Error loading search space"
+ : "Unknown Search Space"),
+ };
+ }
+ return updated;
+ }, [navSecondary, isClient, searchSpace?.name, isLoadingSearchSpace, searchSpaceError]);
+
+ // Show loading state if not client-side
+ if (!isClient) {
+ return ;
}
return (
<>
-
+
- {/* Delete Confirmation Dialog - Only render on client */}
- {isClient && (
-
- )}
+ {/* Delete Confirmation Dialog */}
+
>
);
}
diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx
index 20d6736e4..70eb3fb83 100644
--- a/surfsense_web/components/sidebar/app-sidebar.tsx
+++ b/surfsense_web/components/sidebar/app-sidebar.tsx
@@ -17,15 +17,17 @@ import {
Trash2,
Undo2,
} from "lucide-react";
-import { useMemo } from "react";
+import { memo, useMemo } from "react";
import { Logo } from "@/components/Logo";
import { NavMain } from "@/components/sidebar/nav-main";
import { NavProjects } from "@/components/sidebar/nav-projects";
import { NavSecondary } from "@/components/sidebar/nav-secondary";
+import { NavUser } from "@/components/sidebar/nav-user";
import {
Sidebar,
SidebarContent,
+ SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
@@ -64,7 +66,6 @@ const defaultData = {
isActive: true,
items: [],
},
-
{
title: "Documents",
url: "#",
@@ -154,12 +155,12 @@ interface AppSidebarProps extends React.ComponentProps {
navSecondary?: {
title: string;
url: string;
- icon: string; // Changed to string (icon name)
+ icon: string;
}[];
RecentChats?: {
name: string;
url: string;
- icon: string; // Changed to string (icon name)
+ icon: string;
id?: number;
search_space_id?: number;
actions?: {
@@ -168,9 +169,15 @@ interface AppSidebarProps extends React.ComponentProps {
onClick: () => void;
}[];
}[];
+ user?: {
+ name: string;
+ email: string;
+ avatar: string;
+ };
}
-export function AppSidebar({
+// Memoized AppSidebar component for better performance
+export const AppSidebar = memo(function AppSidebar({
navMain = defaultData.navMain,
navSecondary = defaultData.navSecondary,
RecentChats = defaultData.RecentChats,
@@ -180,7 +187,7 @@ export function AppSidebar({
const processedNavMain = useMemo(() => {
return navMain.map((item) => ({
...item,
- icon: iconMap[item.icon] || SquareTerminal, // Fallback to SquareTerminal if icon not found
+ icon: iconMap[item.icon] || SquareTerminal,
}));
}, [navMain]);
@@ -188,7 +195,7 @@ export function AppSidebar({
const processedNavSecondary = useMemo(() => {
return navSecondary.map((item) => ({
...item,
- icon: iconMap[item.icon] || Undo2, // Fallback to Undo2 if icon not found
+ icon: iconMap[item.icon] || Undo2,
}));
}, [navSecondary]);
@@ -197,17 +204,17 @@ export function AppSidebar({
return (
RecentChats?.map((item) => ({
...item,
- icon: iconMap[item.icon] || MessageCircleMore, // Fallback to MessageCircleMore if icon not found
+ icon: iconMap[item.icon] || MessageCircleMore,
})) || []
);
}, [RecentChats]);
return (
-
+
-
+
@@ -221,11 +228,22 @@ export function AppSidebar({
-
+
+
- {processedRecentChats.length > 0 && }
-
+
+ {processedRecentChats.length > 0 && (
+
+
+
+ )}
+
+
+
+ {/* User Profile Section */}
+
+
);
-}
+});
diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx
index 41859b628..f768c7af8 100644
--- a/surfsense_web/components/sidebar/nav-main.tsx
+++ b/surfsense_web/components/sidebar/nav-main.tsx
@@ -1,6 +1,7 @@
"use client";
import { ChevronRight, type LucideIcon } from "lucide-react";
+import { useMemo } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
@@ -15,46 +16,56 @@ import {
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
-export function NavMain({
- items,
-}: {
- items: {
+interface NavItem {
+ title: string;
+ url: string;
+ icon: LucideIcon;
+ isActive?: boolean;
+ items?: {
title: string;
url: string;
- icon: LucideIcon;
- isActive?: boolean;
- items?: {
- title: string;
- url: string;
- }[];
}[];
-}) {
+}
+
+export function NavMain({ items }: { items: NavItem[] }) {
+ // Memoize items to prevent unnecessary re-renders
+ const memoizedItems = useMemo(() => items, [items]);
+
return (
Platform
- {items.map((item, index) => (
+ {memoizedItems.map((item, index) => (
-
+
{item.title}
+
{item.items?.length ? (
<>
-
+
- Toggle
+ Toggle submenu
-
+
{item.items?.map((subItem, subIndex) => (
-
+
{subItem.title}
diff --git a/surfsense_web/components/sidebar/nav-projects.tsx b/surfsense_web/components/sidebar/nav-projects.tsx
index 1ce323c24..cd0245bb5 100644
--- a/surfsense_web/components/sidebar/nav-projects.tsx
+++ b/surfsense_web/components/sidebar/nav-projects.tsx
@@ -1,17 +1,27 @@
"use client";
-import { ExternalLink, Folder, type LucideIcon, MoreHorizontal, Share, Trash2 } from "lucide-react";
+import {
+ ExternalLink,
+ Folder,
+ type LucideIcon,
+ MoreHorizontal,
+ RefreshCw,
+ Search,
+ Share,
+ Trash2,
+} from "lucide-react";
import { useRouter } from "next/navigation";
+import { useCallback, useMemo, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
+ SidebarInput,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
@@ -26,6 +36,8 @@ const actionIconMap: Record = {
Share,
Trash2,
MoreHorizontal,
+ Search,
+ RefreshCw,
};
interface ChatAction {
@@ -34,33 +46,57 @@ interface ChatAction {
onClick: () => void;
}
-export function NavProjects({
- chats,
-}: {
- chats: {
- name: string;
- url: string;
- icon: LucideIcon;
- id?: number;
- search_space_id?: number;
- actions?: ChatAction[];
- }[];
-}) {
+interface ChatItem {
+ name: string;
+ url: string;
+ icon: LucideIcon;
+ id?: number;
+ search_space_id?: number;
+ actions?: ChatAction[];
+}
+
+export function NavProjects({ chats }: { chats: ChatItem[] }) {
const { isMobile } = useSidebar();
const router = useRouter();
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isDeleting, setIsDeleting] = useState(null);
const searchSpaceId = chats[0]?.search_space_id || "";
- return (
-
- Recent Chats
-
- {chats.map((item, index) => (
-
-
-
- {item.name}
-
+ // Memoized filtered chats
+ const filteredChats = useMemo(() => {
+ if (!searchQuery.trim()) return chats;
+
+ return chats.filter((chat) => chat.name.toLowerCase().includes(searchQuery.toLowerCase()));
+ }, [chats, searchQuery]);
+
+ // Handle chat deletion with loading state
+ const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => {
+ setIsDeleting(chatId);
+ try {
+ await deleteAction();
+ } finally {
+ setIsDeleting(null);
+ }
+ }, []);
+
+ // Enhanced chat item component
+ const ChatItemComponent = useCallback(
+ ({ chat }: { chat: ChatItem }) => {
+ const isDeletingChat = isDeleting === chat.id;
+
+ return (
+
+ router.push(chat.url)}
+ disabled={isDeletingChat}
+ className={isDeletingChat ? "opacity-50" : ""}
+ >
+
+ {chat.name}
+
+
+ {chat.actions && chat.actions.length > 0 && (
@@ -73,44 +109,79 @@ export function NavProjects({
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
- {item.actions ? (
- // Use the actions provided by the item
- item.actions.map((action, actionIndex) => {
- const ActionIcon = actionIconMap[action.icon] || Folder;
- return (
-
-
- {action.name}
-
- );
- })
- ) : (
- // Default actions if none provided
- <>
-
-
- View Chat
+ {chat.actions.map((action, actionIndex) => {
+ const ActionIcon = actionIconMap[action.icon] || Folder;
+ const isDeleteAction = action.name.toLowerCase().includes("delete");
+
+ return (
+ {
+ if (isDeleteAction) {
+ handleDeleteChat(chat.id || 0, action.onClick);
+ } else {
+ action.onClick();
+ }
+ }}
+ disabled={isDeletingChat}
+ className={isDeleteAction ? "text-destructive" : ""}
+ >
+
+ {isDeletingChat && isDeleteAction ? "Deleting..." : action.name}
-
-
-
- Delete Chat
-
- >
- )}
+ );
+ })}
-
- ))}
-
- router.push(`/dashboard/${searchSpaceId}/chats`)}>
-
- View All Chats
-
+ )}
+ );
+ },
+ [isDeleting, router, isMobile, handleDeleteChat]
+ );
+
+ // Show search input if there are chats
+ const showSearch = chats.length > 0;
+
+ return (
+
+ Recent Chats
+
+ {/* Search Input */}
+ {showSearch && (
+
+ setSearchQuery(e.target.value)}
+ className="h-8"
+ />
+
+ )}
+
+
+ {/* Chat Items */}
+ {filteredChats.length > 0 ? (
+ filteredChats.map((chat) => )
+ ) : (
+ /* No results state */
+
+
+
+ {searchQuery ? "No chats found" : "No recent chats"}
+
+
+ )}
+
+ {/* View All Chats */}
+ {chats.length > 0 && (
+
+ router.push(`/dashboard/${searchSpaceId}/chats`)}>
+
+ View All Chats
+
+
+ )}
);
diff --git a/surfsense_web/components/sidebar/nav-secondary.tsx b/surfsense_web/components/sidebar/nav-secondary.tsx
index f292ba75b..ec2defa0e 100644
--- a/surfsense_web/components/sidebar/nav-secondary.tsx
+++ b/surfsense_web/components/sidebar/nav-secondary.tsx
@@ -2,6 +2,7 @@
import type { LucideIcon } from "lucide-react";
import type * as React from "react";
+import { useMemo } from "react";
import {
SidebarGroup,
@@ -11,23 +12,28 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
+interface NavSecondaryItem {
+ title: string;
+ url: string;
+ icon: LucideIcon;
+}
+
export function NavSecondary({
items,
...props
}: {
- items: {
- title: string;
- url: string;
- icon: LucideIcon;
- }[];
+ items: NavSecondaryItem[];
} & React.ComponentPropsWithoutRef) {
+ // Memoize items to prevent unnecessary re-renders
+ const memoizedItems = useMemo(() => items, [items]);
+
return (
SearchSpace
- {items.map((item, index) => (
+ {memoizedItems.map((item, index) => (
-
+
{item.title}
diff --git a/surfsense_web/components/sidebar/nav-user.tsx b/surfsense_web/components/sidebar/nav-user.tsx
index 62229cb74..114f42aef 100644
--- a/surfsense_web/components/sidebar/nav-user.tsx
+++ b/surfsense_web/components/sidebar/nav-user.tsx
@@ -1,7 +1,8 @@
"use client";
-import { BadgeCheck, ChevronsUpDown, LogOut, Settings } from "lucide-react";
+import { BadgeCheck, ChevronsUpDown, LogOut, Settings, User as UserIcon } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
+import { memo, useCallback, useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
@@ -13,90 +14,163 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
+ SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
+import { apiClient } from "@/lib/api";
-export function NavUser({
- user,
-}: {
- user: {
- name: string;
- email: string;
- avatar: string;
- };
-}) {
+interface User {
+ id: string;
+ email: string;
+ is_active: boolean;
+ is_superuser: boolean;
+ is_verified: boolean;
+}
+
+interface UserData {
+ name: string;
+ email: string;
+ avatar: string;
+}
+
+// Memoized NavUser component for better performance
+export const NavUser = memo(function NavUser() {
const { isMobile } = useSidebar();
const router = useRouter();
const { search_space_id } = useParams();
- const handleLogout = () => {
+ // User state management
+ const [user, setUser] = useState(null);
+ const [isLoadingUser, setIsLoadingUser] = useState(true);
+ const [userError, setUserError] = useState(null);
+
+ // Fetch user details
+ useEffect(() => {
+ const fetchUser = async () => {
+ try {
+ if (typeof window === "undefined") return;
+
+ try {
+ const userData = await apiClient.get("users/me");
+ setUser(userData);
+ setUserError(null);
+ } catch (error) {
+ console.error("Error fetching user:", error);
+ setUserError(error instanceof Error ? error.message : "Unknown error occurred");
+ } finally {
+ setIsLoadingUser(false);
+ }
+ } catch (error) {
+ console.error("Error in fetchUser:", error);
+ setIsLoadingUser(false);
+ }
+ };
+
+ fetchUser();
+ }, []);
+
+ // Create user object for display
+ const userData: UserData = {
+ name: user?.email ? user.email.split("@")[0] : "User",
+ email:
+ user?.email ||
+ (isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"),
+ avatar: "/icon-128.png", // Default avatar
+ };
+
+ // Memoized logout handler
+ const handleLogout = useCallback(() => {
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");
}
- };
+ }, [router]);
+
+ // Get user initials for avatar fallback
+ const userInitials = userData.name
+ .split(" ")
+ .map((n: string) => n[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2);
+
return (
-
-
-
-
-
-
-
- CN
-
-
- {user.name}
- {user.email}
-
-
-
-
-
-
-
+
+
+
+
+
+
-
- CN
+
+
+ {userInitials || }
+
- {user.name}
- {user.email}
+ {userData.name}
+ {userData.email}
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {userInitials || }
+
+
+
+ {userData.name}
+ {userData.email}
+
+
+
+
+
+ router.push(`/dashboard/${search_space_id}/api-key`)}
+ aria-label="Manage API key"
+ >
+
+ API Key
+
+
+
router.push(`/dashboard/${search_space_id}/api-key`)}
+ onClick={() => router.push(`/settings`)}
+ aria-label="Go to settings"
>
-
- API Key
+
+ Settings
-
-
- router.push(`/settings`)}>
-
- Settings
-
-
-
- Log out
-
-
-
-
-
+
+
+ Sign out
+
+
+
+
+
+
);
-}
+});
diff --git a/surfsense_web/components/ui/breadcrumb.tsx b/surfsense_web/components/ui/breadcrumb.tsx
new file mode 100644
index 000000000..e2451573f
--- /dev/null
+++ b/surfsense_web/components/ui/breadcrumb.tsx
@@ -0,0 +1,100 @@
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};