diff --git a/surfsense_backend/app/routes/chats_routes.py b/surfsense_backend/app/routes/chats_routes.py index e01b85795..21af85c0f 100644 --- a/surfsense_backend/app/routes/chats_routes.py +++ b/surfsense_backend/app/routes/chats_routes.py @@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.db import Chat, SearchSpace, User, get_async_session -from app.schemas import AISDKChatRequest, ChatCreate, ChatRead, ChatUpdate +from app.schemas import ( + AISDKChatRequest, + ChatCreate, + ChatRead, + ChatReadWithoutMessages, + ChatUpdate, +) from app.tasks.stream_connector_search_results import stream_connector_search_results from app.users import current_active_user from app.utils.check_ownership import check_ownership @@ -112,7 +118,7 @@ async def create_chat( ) from None -@router.get("/chats/", response_model=list[ChatRead]) +@router.get("/chats/", response_model=list[ChatReadWithoutMessages]) async def read_chats( skip: int = 0, limit: int = 100, @@ -121,14 +127,26 @@ async def read_chats( user: User = Depends(current_active_user), ): try: - query = select(Chat).join(SearchSpace).filter(SearchSpace.user_id == user.id) + # Select specific fields excluding messages + query = ( + select( + Chat.id, + Chat.type, + Chat.title, + Chat.initial_connectors, + Chat.search_space_id, + Chat.created_at, + ) + .join(SearchSpace) + .filter(SearchSpace.user_id == user.id) + ) # Filter by search_space_id if provided if search_space_id is not None: query = query.filter(Chat.search_space_id == search_space_id) result = await session.execute(query.offset(skip).limit(limit)) - return result.scalars().all() + return result.all() except OperationalError: raise HTTPException( status_code=503, detail="Database operation failed. Please try again later." diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index e38d534af..c038d9cfd 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -1,5 +1,12 @@ from .base import IDModel, TimestampModel -from .chats import AISDKChatRequest, ChatBase, ChatCreate, ChatRead, ChatUpdate +from .chats import ( + AISDKChatRequest, + ChatBase, + ChatCreate, + ChatRead, + ChatReadWithoutMessages, + ChatUpdate, +) from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate from .documents import ( DocumentBase, @@ -37,6 +44,7 @@ __all__ = [ "ChatBase", "ChatCreate", "ChatRead", + "ChatReadWithoutMessages", "ChatUpdate", "ChunkBase", "ChunkCreate", diff --git a/surfsense_backend/app/schemas/chats.py b/surfsense_backend/app/schemas/chats.py index 1dfedef53..59c274dc5 100644 --- a/surfsense_backend/app/schemas/chats.py +++ b/surfsense_backend/app/schemas/chats.py @@ -15,6 +15,12 @@ class ChatBase(BaseModel): search_space_id: int +class ChatBaseWithoutMessages(BaseModel): + type: ChatType + title: str + search_space_id: int + + class ClientAttachment(BaseModel): name: str content_type: str @@ -50,3 +56,7 @@ class ChatUpdate(ChatBase): class ChatRead(ChatBase, IDModel, TimestampModel): model_config = ConfigDict(from_attributes=True) + + +class ChatReadWithoutMessages(ChatBaseWithoutMessages, IDModel, TimestampModel): + model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index dbad14eba..1222733d0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -19,14 +19,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, @@ -61,24 +54,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; interface Chat { created_at: string; id: number; type: string; title: string; - messages: ChatMessage[]; search_space_id: number; } -interface ChatMessage { - id: string; - createdAt: string; - role: string; - content: string; - parts?: any; -} - interface ChatsPageClientProps { searchSpaceId: string; } @@ -580,12 +565,12 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) animate="animate" exit="exit" transition={{ duration: 0.2, delay: index * 0.05 }} - className={`overflow-hidden hover:shadow-md transition-shadow - ${ - selectionMode && selectedChats.includes(chat.id) - ? "ring-2 ring-primary ring-offset-2" - : "" - }`} + className={cn( + "overflow-hidden hover:shadow-md transition-shadow", + selectionMode && selectedChats.includes(chat.id) + ? "ring-2 ring-primary ring-offset-2" + : "" + )} onClick={(e) => { if (!selectionMode) return; // Ignore clicks coming from interactive elements @@ -672,24 +657,21 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) )} - -
- {chat.messages && chat.messages.length > 0 - ? typeof chat.messages[0] === "string" - ? chat.messages[0] - : chat.messages[0]?.content || "No message content" - : "No messages in this chat."} -
-
- -
- - {chat.messages?.length || 0} messages -
+ + {chat.type || "Unknown"} + ))} diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 97c7894c0..6610cb046 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -1,6 +1,8 @@ "use client"; import type React from "react"; +import { useState } from "react"; +import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { Separator } from "@/components/ui/separator"; @@ -17,8 +19,18 @@ export function DashboardClientLayout({ navSecondary: any[]; navMain: any[]; }) { + const [open, setOpen] = useState(() => { + try { + const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/); + if (match) return match[1] === "true"; + } catch { + // ignore + } + return true; + }); + return ( - + {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} -
-
- - +
+
+
+ + + +
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx new file mode 100644 index 000000000..e0cc12b0a --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + IconBook, + IconBrandDiscord, + IconBrandGithub, + IconBrandNotion, + IconBrandSlack, + IconBrandYoutube, + IconCalendar, + IconChecklist, + IconLayoutKanban, + IconTicket, +} from "@tabler/icons-react"; +import { File, Globe, Webhook } from "lucide-react"; +import type React from "react"; + +type IconComponent = React.ComponentType<{ size?: number; className?: string }>; + +const documentTypeIcons: Record = { + EXTENSION: Webhook, + CRAWLED_URL: Globe, + SLACK_CONNECTOR: IconBrandSlack, + NOTION_CONNECTOR: IconBrandNotion, + FILE: File, + YOUTUBE_VIDEO: IconBrandYoutube, + GITHUB_CONNECTOR: IconBrandGithub, + LINEAR_CONNECTOR: IconLayoutKanban, + JIRA_CONNECTOR: IconTicket, + DISCORD_CONNECTOR: IconBrandDiscord, + CONFLUENCE_CONNECTOR: IconBook, + CLICKUP_CONNECTOR: IconChecklist, + GOOGLE_CALENDAR_CONNECTOR: IconCalendar, +}; + +export function getDocumentTypeIcon(type: string): IconComponent { + return documentTypeIcons[type] ?? File; +} + +export function getDocumentTypeLabel(type: string): string { + return type + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); +} + +export function DocumentTypeChip({ type, className }: { type: string; className?: string }) { + const Icon = getDocumentTypeIcon(type); + return ( + + + {getDocumentTypeLabel(type)} + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx new file mode 100644 index 000000000..dd8791223 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { AnimatePresence, motion, type Variants } from "framer-motion"; +import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react"; +import React, { useMemo, useRef } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import type { ColumnVisibility, Document } from "./types"; + +const fadeInScale: Variants = { + hidden: { opacity: 0, scale: 0.95 }, + visible: { opacity: 1, scale: 1, transition: { type: "spring", stiffness: 300, damping: 30 } }, + exit: { opacity: 0, scale: 0.95, transition: { duration: 0.15 } }, +}; + +export function DocumentsFilters({ + allDocuments, + visibleDocuments: _visibleDocuments, + selectedIds, + onSearch, + searchValue, + onBulkDelete, + onToggleType, + activeTypes, + columnVisibility, + onToggleColumn, +}: { + allDocuments: Document[]; + visibleDocuments: Document[]; + selectedIds: Set; + onSearch: (v: string) => void; + searchValue: string; + onBulkDelete: () => Promise; + onToggleType: (type: string, checked: boolean) => void; + activeTypes: string[]; + columnVisibility: ColumnVisibility; + onToggleColumn: (id: keyof ColumnVisibility, checked: boolean) => void; +}) { + const id = React.useId(); + const inputRef = useRef(null); + + const uniqueTypes = useMemo(() => { + const set = new Set(); + for (const d of allDocuments) set.add(d.document_type); + return Array.from(set).sort(); + }, [allDocuments]); + + const typeCounts = useMemo(() => { + const map = new Map(); + for (const d of allDocuments) map.set(d.document_type, (map.get(d.document_type) ?? 0) + 1); + return map; + }, [allDocuments]); + + return ( + +
+ + onSearch(e.target.value)} + placeholder="Filter by title..." + type="text" + aria-label="Filter by title" + /> + + + {Boolean(searchValue) && ( + { + onSearch(""); + inputRef.current?.focus(); + }} + initial={{ opacity: 0, rotate: -90 }} + animate={{ opacity: 1, rotate: 0 }} + exit={{ opacity: 0, rotate: 90 }} + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + + )} + + + + + + + + + + +
+
Filters
+
+ + {uniqueTypes.map((value, i) => ( + + onToggleType(value, !!checked)} + /> + + + ))} + +
+
+
+
+
+ + + + + + + + + Toggle columns + {( + [ + ["title", "Title"], + ["document_type", "Type"], + ["content", "Content"], + ["created_at", "Created At"], + ] as Array<[keyof ColumnVisibility, string]> + ).map(([key, label]) => ( + onToggleColumn(key, !!v)} + onSelect={(e) => e.preventDefault()} + > + {label} + + ))} + + +
+ +
+ {selectedIds.size > 0 && ( + + + + + +
+ + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete {selectedIds.size}{" "} + selected {selectedIds.size === 1 ? "row" : "rows"}. + + +
+ + Cancel + Delete + +
+
+ )} +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx new file mode 100644 index 000000000..12da30045 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ChevronDown, ChevronUp, FileX } from "lucide-react"; +import React from "react"; +import { DocumentViewer } from "@/components/document-viewer"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { DocumentTypeChip, getDocumentTypeIcon } from "./DocumentTypeIcon"; +import { RowActions } from "./RowActions"; +import type { ColumnVisibility, Document } from "./types"; + +export type SortKey = keyof Pick; + +function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[] { + const sorted = [...docs].sort((a, b) => { + const av = a[key] ?? ""; + const bv = b[key] ?? ""; + if (key === "created_at") + return new Date(av as string).getTime() - new Date(bv as string).getTime(); + return String(av).localeCompare(String(bv)); + }); + return desc ? sorted.reverse() : sorted; +} + +function truncate(text: string, len = 150): string { + const plain = text + .replace(/[#*_`>\-[\]()]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (plain.length <= len) return plain; + return `${plain.slice(0, len)}...`; +} + +export function DocumentsTableShell({ + documents, + loading, + error, + onRefresh, + selectedIds, + setSelectedIds, + columnVisibility, + deleteDocument, + sortKey, + sortDesc, + onSortChange, +}: { + documents: Document[]; + loading: boolean; + error: boolean; + onRefresh: () => Promise; + selectedIds: Set; + setSelectedIds: (update: Set) => void; + columnVisibility: ColumnVisibility; + deleteDocument: (id: number) => Promise; + sortKey: SortKey; + sortDesc: boolean; + onSortChange: (key: SortKey) => void; +}) { + const sorted = React.useMemo( + () => sortDocuments(documents, sortKey, sortDesc), + [documents, sortKey, sortDesc] + ); + + const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id)); + const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; + + const toggleAll = (checked: boolean) => { + const next = new Set(selectedIds); + if (checked) sorted.forEach((d) => next.add(d.id)); + else sorted.forEach((d) => next.delete(d.id)); + setSelectedIds(next); + }; + + const toggleOne = (id: number, checked: boolean) => { + const next = new Set(selectedIds); + if (checked) next.add(id); + else next.delete(id); + setSelectedIds(next); + }; + + const onSortHeader = (key: SortKey) => onSortChange(key); + + return ( + + {loading ? ( +
+
+
+

Loading documents...

+
+
+ ) : error ? ( +
+
+

Error loading documents

+ +
+
+ ) : sorted.length === 0 ? ( +
+
+ +

No documents found

+
+
+ ) : ( + <> +
+ + + + + toggleAll(!!v)} + aria-label="Select all" + /> + + {columnVisibility.title && ( + + + + )} + {columnVisibility.document_type && ( + + + + )} + {columnVisibility.content && ( + Content Summary + )} + {columnVisibility.created_at && ( + + + + )} + + Actions + + + + + {sorted.map((doc, index) => { + const Icon = getDocumentTypeIcon(doc.document_type); + const title = doc.title; + const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; + return ( + + + toggleOne(doc.id, !!v)} + aria-label="Select row" + /> + + {columnVisibility.title && ( + + + + + + + {truncatedTitle} + + + +

{title}

+
+
+
+
+ )} + {columnVisibility.document_type && ( + +
+ +
+
+ )} + {columnVisibility.content && ( + +
+
+ {truncate(doc.content)} +
+ + View Full Content + + } + /> +
+
+ )} + {columnVisibility.created_at && ( + + {new Date(doc.created_at).toLocaleDateString()} + + )} + + { + await onRefresh(); + }} + /> + +
+ ); + })} +
+
+
+
+ {sorted.map((doc) => { + const Icon = getDocumentTypeIcon(doc.document_type); + return ( +
+
+ toggleOne(doc.id, !!v)} + aria-label="Select row" + /> +
+
+
+ +
{doc.title}
+
+ { + await onRefresh(); + }} + /> +
+
+ + + {new Date(doc.created_at).toLocaleDateString()} + +
+ {columnVisibility.content && ( +
+ {truncate(doc.content)} +
+ + View Full Content + + } + /> +
+
+ )} +
+
+
+ ); + })} +
+ + )} +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx new file mode 100644 index 000000000..2e6f8f314 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export function PaginationControls({ + pageIndex, + pageSize, + total, + onPageSizeChange, + onFirst, + onPrev, + onNext, + onLast, + canPrev, + canNext, + id, +}: { + pageIndex: number; + pageSize: number; + total: number; + onPageSizeChange: (size: number) => void; + onFirst: () => void; + onPrev: () => void; + onNext: () => void; + onLast: () => void; + canPrev: boolean; + canNext: boolean; + id: string; +}) { + const start = total === 0 ? 0 : pageIndex * pageSize + 1; + const end = Math.min((pageIndex + 1) * pageSize, total); + + return ( +
+ + + + + + +

+ + {start}-{end} + {" "} + of {total} +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx new file mode 100644 index 000000000..bd1e182d9 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { MoreHorizontal } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { Document } from "./types"; + +export function RowActions({ + document, + deleteDocument, + refreshDocuments, +}: { + document: Document; + deleteDocument: (id: number) => Promise; + refreshDocuments: () => Promise; +}) { + const [isOpen, setIsOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + try { + const ok = await deleteDocument(document.id); + if (ok) toast.success("Document deleted successfully"); + else toast.error("Failed to delete document"); + await refreshDocuments(); + } catch (error) { + console.error("Error deleting document:", error); + toast.error("Failed to delete document"); + } finally { + setIsDeleting(false); + setIsOpen(false); + } + }; + + return ( +
+ + + + + + e.preventDefault()}> + View Metadata + + } + /> + + + + { + e.preventDefault(); + setIsOpen(true); + }} + > + Delete + + + + + Are you sure? + + + Cancel + { + e.preventDefault(); + handleDelete(); + }} + disabled={isDeleting} + > + {isDeleting ? "Deleting..." : "Delete"} + + + + + + +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/types.ts b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/types.ts new file mode 100644 index 000000000..73b68b588 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/types.ts @@ -0,0 +1,18 @@ +export type DocumentType = string; + +export type Document = { + id: number; + title: string; + document_type: DocumentType; + document_metadata: any; + content: string; + created_at: string; + search_space_id: number; +}; + +export type ColumnVisibility = { + title: boolean; + document_type: boolean; + content: boolean; + created_at: boolean; +}; diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx index dd226c7c0..4a69a7533 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx @@ -1,1067 +1,169 @@ "use client"; -import { - IconBook, - IconBrandDiscord, - IconBrandGithub, - IconBrandNotion, - IconBrandSlack, - IconBrandYoutube, - IconCalendar, - IconChecklist, - IconLayoutKanban, - IconTicket, -} from "@tabler/icons-react"; -import { - type ColumnDef, - type ColumnFiltersState, - flexRender, - getCoreRowModel, - getFacetedUniqueValues, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - type PaginationState, - type Row, - type SortingState, - useReactTable, - type VisibilityState, -} from "@tanstack/react-table"; -import { AnimatePresence, motion, type Variants } from "framer-motion"; -import { - AlertCircle, - ChevronDown, - ChevronFirst, - ChevronLast, - ChevronLeft, - ChevronRight, - ChevronUp, - CircleAlert, - CircleX, - Columns3, - File, - FileX, - Filter, - Globe, - ListFilter, - MoreHorizontal, - Trash, - Webhook, -} from "lucide-react"; +import { motion } from "framer-motion"; import { useParams } from "next/navigation"; -import React, { useContext, useEffect, useId, useMemo, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize from "rehype-sanitize"; -import remarkGfm from "remark-gfm"; +import { useEffect, useId, useMemo, useState } from "react"; import { toast } from "sonner"; -import { DocumentViewer } from "@/components/document-viewer"; -import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + import { useDocuments } from "@/hooks/use-documents"; -import { cn } from "@/lib/utils"; -// Define animation variants for reuse -const fadeInScale: Variants = { - hidden: { opacity: 0, scale: 0.95 }, - visible: { - opacity: 1, - scale: 1, - transition: { type: "spring", stiffness: 300, damping: 30 }, - }, - exit: { - opacity: 0, - scale: 0.95, - transition: { duration: 0.15 }, - }, -}; +import { DocumentsFilters } from "./components/DocumentsFilters"; +import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; +import { PaginationControls } from "./components/PaginationControls"; +import type { ColumnVisibility, Document } from "./components/types"; -type Document = { - id: number; - title: string; - document_type: - | "EXTENSION" - | "CRAWLED_URL" - | "SLACK_CONNECTOR" - | "NOTION_CONNECTOR" - | "FILE" - | "YOUTUBE_VIDEO" - | "LINEAR_CONNECTOR" - | "DISCORD_CONNECTOR"; - document_metadata: any; - content: string; - created_at: string; - search_space_id: number; -}; - -// Add document type icons mapping -const documentTypeIcons = { - EXTENSION: Webhook, - CRAWLED_URL: Globe, - SLACK_CONNECTOR: IconBrandSlack, - NOTION_CONNECTOR: IconBrandNotion, - FILE: File, - YOUTUBE_VIDEO: IconBrandYoutube, - GITHUB_CONNECTOR: IconBrandGithub, - LINEAR_CONNECTOR: IconLayoutKanban, - JIRA_CONNECTOR: IconTicket, - DISCORD_CONNECTOR: IconBrandDiscord, - CONFLUENCE_CONNECTOR: IconBook, - CLICKUP_CONNECTOR: IconChecklist, - GOOGLE_CALENDAR_CONNECTOR: IconCalendar, -} as const; - -const columns: ColumnDef[] = [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - size: 28, - enableSorting: false, - enableHiding: false, - }, - { - header: "Title", - accessorKey: "title", - cell: ({ row }) => { - const Icon = documentTypeIcons[row.original.document_type]; - const title = row.getValue("title") as string; - const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; - return ( - - - - - - {truncatedTitle} - - - -

{title}

-
-
-
- ); - }, - size: 250, - }, - { - header: "Type", - accessorKey: "document_type", - cell: ({ row }) => { - const type = row.getValue("document_type") as keyof typeof documentTypeIcons; - const Icon = documentTypeIcons[type]; - return ( -
-
- -
- - {type - .split("_") - .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) - .join(" ")} - -
- ); - }, - size: 180, - enableColumnFilter: true, - }, - { - header: "Content Summary", - accessorKey: "content", - cell: ({ row }) => { - const content = row.getValue("content") as string; - const title = row.getValue("title") as string; - - // Create a truncated preview (first 150 characters) - const previewContent = content.length > 150 ? `${content.substring(0, 150)}...` : content; - - return ( - - ); - }, - size: 300, - }, - { - header: "Created At", - accessorKey: "created_at", - cell: ({ row }) => { - const date = new Date(row.getValue("created_at")); - return date.toLocaleDateString(); - }, - size: 120, - }, - { - id: "actions", - header: () => Actions, - cell: ({ row }) => , - size: 60, - enableHiding: false, - }, -]; - -// Create a context to share the deleteDocument function -const DocumentsContext = React.createContext<{ - deleteDocument: (id: number) => Promise; - refreshDocuments: () => Promise; -} | null>(null); +function useDebounced(value: T, delay = 250) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(t); + }, [value, delay]); + return debounced; +} export default function DocumentsTable() { const id = useId(); const params = useParams(); const searchSpaceId = Number(params.search_space_id); + const { documents, loading, error, refreshDocuments, deleteDocument } = useDocuments(searchSpaceId); - // console.log("Search Space ID:", searchSpaceId); - // console.log("Documents loaded:", documents?.length); - - useEffect(() => { - console.log("Delete document function available:", !!deleteDocument); - }, [deleteDocument]); - - const [columnFilters, setColumnFilters] = useState([]); - const [columnVisibility, setColumnVisibility] = useState({}); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 10, - }); - const inputRef = useRef(null); - - const [sorting, setSorting] = useState([ - { - id: "title", - desc: false, - }, - ]); - const [data, setData] = useState([]); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounced(search, 250); + const [activeTypes, setActiveTypes] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({ + title: true, + document_type: true, + content: true, + created_at: true, + }); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [sortKey, setSortKey] = useState("title"); + const [sortDesc, setSortDesc] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); useEffect(() => { - if (documents) { - setData(documents as Document[]); - } + if (documents) setData(documents as Document[]); }, [documents]); - const handleDeleteRows = async () => { - const selectedRows = table.getSelectedRowModel().rows; - // console.log("Deleting selected rows:", selectedRows.length); + const filtered = useMemo(() => { + let result = data; + if (debouncedSearch.trim()) { + const q = debouncedSearch.toLowerCase(); + result = result.filter((d) => d.title.toLowerCase().includes(q)); + } + if (activeTypes.length > 0) { + result = result.filter((d) => activeTypes.includes(d.document_type)); + } + return result; + }, [data, debouncedSearch, activeTypes]); - if (selectedRows.length === 0) { + const total = filtered.length; + const pageStart = pageIndex * pageSize; + const pageEnd = Math.min(pageStart + pageSize, total); + const pageDocs = filtered.slice(pageStart, pageEnd); + + const onToggleType = (type: string, checked: boolean) => { + setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type))); + setPageIndex(0); + }; + + const onToggleColumn = (id: keyof ColumnVisibility, checked: boolean) => { + setColumnVisibility((prev) => ({ ...prev, [id]: checked })); + }; + + const onBulkDelete = async () => { + if (selectedIds.size === 0) { toast.error("No rows selected"); return; } - - // Create an array of promises for each delete operation - const deletePromises = selectedRows.map((row) => { - // console.log("Deleting row with ID:", row.original.id); - return deleteDocument(row.original.id); - }); - try { - // Execute all delete operations - const results = await Promise.all(deletePromises); - // console.log("Delete results:", results); - - // Check if all deletions were successful - const allSuccessful = results.every((result) => result === true); - - if (allSuccessful) { - toast.success(`Successfully deleted ${selectedRows.length} document(s)`); - } else { - toast.error("Some documents could not be deleted"); - } - - // Refresh the documents list after all deletions - await refreshDocuments(); - table.resetRowSelection(); - } catch (error: any) { - console.error("Error deleting documents:", error); + const results = await Promise.all(Array.from(selectedIds).map((id) => deleteDocument?.(id))); + const okCount = results.filter((r) => r === true).length; + if (okCount === selectedIds.size) + toast.success(`Successfully deleted ${okCount} document(s)`); + else toast.error("Some documents could not be deleted"); + await refreshDocuments?.(); + setSelectedIds(new Set()); + } catch (e) { + console.error(e); toast.error("Error deleting documents"); } }; - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - onSortingChange: setSorting, - enableSortingRemoval: false, - getPaginationRowModel: getPaginationRowModel(), - onPaginationChange: setPagination, - onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, - getFilteredRowModel: getFilteredRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - state: { - sorting, - pagination, - columnFilters, - columnVisibility, - }, - }); - - // Get unique status values - const uniqueStatusValues = useMemo(() => { - const statusColumn = table.getColumn("document_type"); - if (!data.length) return []; // Don't compute until data is present - - if (!statusColumn) return []; - - const values = Array.from(statusColumn.getFacetedUniqueValues().keys()); - - return values.sort(); - }, [table.getColumn, data]); - - // Get counts for each status - const statusCounts = useMemo(() => { - const statusColumn = table.getColumn("document_type"); - if (!data.length) return new Map(); // Don't compute until data is present - if (!statusColumn) return new Map(); - return statusColumn.getFacetedUniqueValues(); - }, [table.getColumn, data, columnFilters]); - - const selectedStatuses = useMemo(() => { - const filterValue = table.getColumn("document_type")?.getFilterValue() as string[]; - if (!data.length) return []; // Don't compute until data is present - - return filterValue ?? []; - }, [table.getColumn, data, columnFilters]); - - const handleStatusChange = (checked: boolean, value: string) => { - const filterValue = table.getColumn("document_type")?.getFilterValue() as string[]; - const newFilterValue = filterValue ? [...filterValue] : []; - - if (checked) { - newFilterValue.push(value); - } else { - const index = newFilterValue.indexOf(value); - if (index > -1) { - newFilterValue.splice(index, 1); - } - } - - setColumnFilters([ - { - id: "document_type", - value: newFilterValue, - }, - ]); - - table - .getColumn("document_type") - ?.setFilterValue(newFilterValue.length ? newFilterValue : undefined); - }; + useEffect(() => { + const mq = window.matchMedia("(max-width: 768px)"); + const apply = (isSmall: boolean) => { + setColumnVisibility((prev) => ({ ...prev, content: !isSmall, created_at: !isSmall })); + }; + apply(mq.matches); + const onChange = (e: MediaQueryListEvent) => apply(e.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); return ( - Promise.resolve(false)), - refreshDocuments: refreshDocuments || (() => Promise.resolve()), - }} + - - {/* Filters */} - -
- {/* Filter by name or email */} - - table.getColumn("title")?.setFilterValue(e.target.value)} - placeholder="Filter by title..." - type="text" - aria-label="Filter by title" - /> - - - {Boolean(table.getColumn("title")?.getFilterValue()) && ( - { - table.getColumn("title")?.setFilterValue(""); - if (inputRef.current) { - inputRef.current.focus(); - } - }} - initial={{ opacity: 0, rotate: -90 }} - animate={{ opacity: 1, rotate: 0 }} - exit={{ opacity: 0, rotate: 90 }} - whileHover={{ scale: 1.1 }} - whileTap={{ scale: 0.9 }} - > - - )} - - {/* Filter by status */} - - - - - - - - -
-
Filters
-
- - {uniqueStatusValues.map((value, i) => ( - - - handleStatusChange(checked, value) - } - /> - - - ))} - -
-
-
-
-
- {/* Toggle columns visibility */} - - - - - - - - - Toggle columns - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - column.toggleVisibility(!!value)} - onSelect={(event) => event.preventDefault()} - > - {column.id} - - ); - })} - - - -
-
- {/* Delete button */} - {table.getSelectedRowModel().rows.length > 0 && ( - - - - - -
- - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete{" "} - {table.getSelectedRowModel().rows.length} selected{" "} - {table.getSelectedRowModel().rows.length === 1 ? "row" : "rows"}. - - -
- - Cancel - Delete - -
-
- )} - {/* Add user button */} - {/* */} -
-
+ - {/* Table */} - - {loading ? ( -
-
-
-

Loading documents...

-
-
- ) : error ? ( -
-
- -

Error loading documents

- -
-
- ) : data.length === 0 ? ( -
-
- -

No documents found

-
-
- ) : ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : header.column.getCanSort() ? ( - - ) : ( - flexRender(header.column.columnDef.header, header.getContext()) - )} - - ); - })} - - ))} - - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row, index) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No documents found. - - - )} - - -
- )} -
+ { + await (refreshDocuments?.() ?? Promise.resolve()); + }} + selectedIds={selectedIds} + setSelectedIds={setSelectedIds} + columnVisibility={columnVisibility} + deleteDocument={(id) => deleteDocument?.(id) ?? Promise.resolve(false)} + sortKey={sortKey} + sortDesc={sortDesc} + onSortChange={(key) => { + if (sortKey === key) setSortDesc((v) => !v); + else { + setSortKey(key); + setSortDesc(false); + } + }} + /> - {/* Pagination */} -
- {/* Results per page */} - - - - - {/* Page number information */} - -

- - {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}- - {Math.min( - Math.max( - table.getState().pagination.pageIndex * table.getState().pagination.pageSize + - table.getState().pagination.pageSize, - 0 - ), - table.getRowCount() - )} - {" "} - of {table.getRowCount().toString()} -

-
- - {/* Pagination buttons */} -
- - - {/* First page button */} - - - - - - {/* Previous page button */} - - - - - - {/* Next page button */} - - - - - - {/* Last page button */} - - - - - - - -
-
-
-
- ); -} - -function RowActions({ row }: { row: Row }) { - const [isOpen, setIsOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const context = useContext(DocumentsContext); - - if (!context) { - throw new Error("DocumentsContext not found"); - } - - const { deleteDocument, refreshDocuments } = context; - const document = row.original; - - const handleDelete = async () => { - setIsDeleting(true); - try { - await deleteDocument(document.id); - toast.success("Document deleted successfully"); - await refreshDocuments(); - } catch (error) { - console.error("Error deleting document:", error); - toast.error("Failed to delete document"); - } finally { - setIsDeleting(false); - setIsOpen(false); - } - }; - - return ( -
- - - - - - e.preventDefault()}> - View Metadata - - } - /> - - - - { - e.preventDefault(); - setIsOpen(true); - }} - > - Delete - - - - - Are you sure? - - This action cannot be undone. This will permanently delete the document. - - - - Cancel - { - e.preventDefault(); - handleDelete(); - }} - disabled={isDeleting} - > - {isDeleting ? "Deleting..." : "Delete"} - - - - - - -
+ { + setPageSize(s); + setPageIndex(0); + }} + onFirst={() => setPageIndex(0)} + onPrev={() => setPageIndex((i) => Math.max(0, i - 1))} + onNext={() => setPageIndex((i) => (pageEnd < total ? i + 1 : i))} + onLast={() => setPageIndex(Math.max(0, Math.ceil(total / pageSize) - 1))} + canPrev={pageIndex > 0} + canNext={pageEnd < total} + id={id} + /> + ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx index 8631c7efd..efea74b49 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx @@ -1,12 +1,17 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Calendar, CheckCircle2, FileType, Tag, Upload, X } from "lucide-react"; +import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; // Grid pattern component inspired by Aceternity UI function GridPattern() { @@ -34,14 +39,13 @@ function GridPattern() { } export default function FileUploader() { - // Use the useParams hook to get the params const params = useParams(); const search_space_id = params.search_space_id as string; const [files, setFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); const router = useRouter(); - const fileInputRef = useRef(null); // Audio files are always supported (using whisper) const audioFileTypes = { @@ -204,7 +208,6 @@ export default function FileUploader() { }; const acceptedFileTypes = getAcceptedFileTypes(); - const supportedExtensions = Array.from(new Set(Object.values(acceptedFileTypes).flat())).sort(); const onDrop = useCallback((acceptedFiles: File[]) => { @@ -215,12 +218,10 @@ export default function FileUploader() { onDrop, accept: acceptedFileTypes, maxSize: 50 * 1024 * 1024, // 50MB + noClick: false, // Ensure clicking is enabled + noKeyboard: false, // Ensure keyboard navigation is enabled }); - const handleClick = () => { - fileInputRef.current?.click(); - }; - const removeFile = (index: number) => { setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index)); }; @@ -235,6 +236,7 @@ export default function FileUploader() { const handleUpload = async () => { setIsUploading(true); + setUploadProgress(0); const formData = new FormData(); files.forEach((file) => { @@ -244,12 +246,16 @@ export default function FileUploader() { formData.append("search_space_id", search_space_id); try { - // toast("File Upload", { - // description: "Files Uploading Initiated", - // }) + // Simulate progress for better UX + const progressInterval = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 90) return prev; + return prev + Math.random() * 10; + }); + }, 200); const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL!}/api/v1/documents/fileupload`, + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`, { method: "POST", headers: { @@ -259,6 +265,9 @@ export default function FileUploader() { } ); + clearInterval(progressInterval); + setUploadProgress(100); + if (!response.ok) { throw new Error("Upload failed"); } @@ -272,31 +281,15 @@ export default function FileUploader() { router.push(`/dashboard/${search_space_id}/documents`); } catch (error: any) { setIsUploading(false); + setUploadProgress(0); toast("Upload Error", { description: `Error uploading files: ${error.message}`, }); } }; - const mainVariant = { - initial: { - x: 0, - y: 0, - }, - animate: { - x: 20, - y: -20, - opacity: 0.9, - }, - }; - - const secondaryVariant = { - initial: { - opacity: 0, - }, - animate: { - opacity: 1, - }, + const getTotalFileSize = () => { + return files.reduce((total, file) => total + file.size, 0); }; const containerVariants = { @@ -326,251 +319,252 @@ export default function FileUploader() { return (
- - + {/* Header Card */} + + + + + + Upload Documents + + + Upload your files to make them searchable and accessible through AI-powered + conversations. + + + + + + + Maximum file size: 50MB per file. Supported formats vary based on your ETL service + configuration. + + + + + + + {/* Upload Area Card */} + + {/* Grid background pattern */} -
+
-
- {/* Dropzone area */} -
- - -

- Upload files -

-

- Drag or drop your files here or click to upload -

- -
- {!files.length && ( - - {isDragActive ? ( - - Drop it - - - ) : ( - - )} - - )} - - {!files.length && ( - - )} -
-
-
- - - {/* File list section */} - - {files.length > 0 && ( - +
-
-

Selected Files ({files.length})

-
+
+ + +
-
- - {files.map((file, index) => ( - -
- - {file.name} - + {/* File List Card */} + + {files.length > 0 && ( + + + +
+
+ Selected Files ({files.length}) + + Total size: {formatFileSize(getTotalFileSize())} + +
+ +
+
+ +
+ + {files.map((file, index) => ( + +
+
+ +
+
+

{file.name}

+
+ + {formatFileSize(file.size)} + + + {file.type || "Unknown type"} + +
+
+
- - {formatFileSize(file.size)} -
-
- -
- - - {file.type || "Unknown type"} - - - - - modified {new Date(file.lastModified).toLocaleDateString()} - -
-
- ))} -
-
- - - - -
- )} -
+ ))} + +
- {/* File type information */} - -
-
- -

Supported file types:

-
+ {isUploading && ( + + +
+
+ Uploading files... + {Math.round(uploadProgress)}% +
+ +
+
+ )} + + + + + + + + )} + + + {/* 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 Chat - - - Are you sure you want to delete{" "} - {chatToDelete?.name}? This action cannot be - undone. - - - - - - - - - )} + {/* Delete Confirmation Dialog */} + + + + + + Delete Chat + + + Are you sure you want to delete{" "} + {chatToDelete?.name}? This action cannot be + undone. + + + + + + + + ); } 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