From a919f8d9ee5c1d6f7205be9661ea1fb756179ee3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 8 Jan 2026 19:10:40 +0200 Subject: [PATCH 1/5] feat: add new layout system (Slack/ClickUp inspired) --- .../components/layout/hooks/index.ts | 1 + .../layout/hooks/useSidebarState.ts | 61 +++ surfsense_web/components/layout/index.ts | 30 ++ .../layout/providers/LayoutDataProvider.tsx | 486 ++++++++++++++++++ .../components/layout/providers/index.ts | 1 + .../components/layout/types/layout.types.ts | 139 +++++ .../components/layout/ui/header/Header.tsx | 49 ++ .../components/layout/ui/header/index.ts | 1 + .../layout/ui/icon-rail/IconRail.tsx | 60 +++ .../layout/ui/icon-rail/NavIcon.tsx | 34 ++ .../layout/ui/icon-rail/WorkspaceAvatar.tsx | 72 +++ .../components/layout/ui/icon-rail/index.ts | 3 + surfsense_web/components/layout/ui/index.ts | 16 + .../layout/ui/shell/LayoutShell.tsx | 203 ++++++++ .../components/layout/ui/shell/index.ts | 1 + .../layout/ui/sidebar/AllChatsSidebar.tsx | 443 ++++++++++++++++ .../layout/ui/sidebar/AllNotesSidebar.tsx | 407 +++++++++++++++ .../layout/ui/sidebar/ChatListItem.tsx | 65 +++ .../layout/ui/sidebar/MobileSidebar.tsx | 154 ++++++ .../layout/ui/sidebar/NavSection.tsx | 73 +++ .../layout/ui/sidebar/NoteListItem.tsx | 76 +++ .../layout/ui/sidebar/PageUsageDisplay.tsx | 34 ++ .../components/layout/ui/sidebar/Sidebar.tsx | 294 +++++++++++ .../ui/sidebar/SidebarCollapseButton.tsx | 31 ++ .../layout/ui/sidebar/SidebarHeader.tsx | 69 +++ .../layout/ui/sidebar/SidebarSection.tsx | 56 ++ .../layout/ui/sidebar/SidebarUserProfile.tsx | 188 +++++++ .../components/layout/ui/sidebar/index.ts | 12 + 28 files changed, 3059 insertions(+) create mode 100644 surfsense_web/components/layout/hooks/index.ts create mode 100644 surfsense_web/components/layout/hooks/useSidebarState.ts create mode 100644 surfsense_web/components/layout/index.ts create mode 100644 surfsense_web/components/layout/providers/LayoutDataProvider.tsx create mode 100644 surfsense_web/components/layout/providers/index.ts create mode 100644 surfsense_web/components/layout/types/layout.types.ts create mode 100644 surfsense_web/components/layout/ui/header/Header.tsx create mode 100644 surfsense_web/components/layout/ui/header/index.ts create mode 100644 surfsense_web/components/layout/ui/icon-rail/IconRail.tsx create mode 100644 surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx create mode 100644 surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx create mode 100644 surfsense_web/components/layout/ui/icon-rail/index.ts create mode 100644 surfsense_web/components/layout/ui/index.ts create mode 100644 surfsense_web/components/layout/ui/shell/LayoutShell.tsx create mode 100644 surfsense_web/components/layout/ui/shell/index.ts create mode 100644 surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/NavSection.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/Sidebar.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx create mode 100644 surfsense_web/components/layout/ui/sidebar/index.ts diff --git a/surfsense_web/components/layout/hooks/index.ts b/surfsense_web/components/layout/hooks/index.ts new file mode 100644 index 000000000..51cf8f7a0 --- /dev/null +++ b/surfsense_web/components/layout/hooks/index.ts @@ -0,0 +1 @@ +export { useSidebarState } from "./useSidebarState"; diff --git a/surfsense_web/components/layout/hooks/useSidebarState.ts b/surfsense_web/components/layout/hooks/useSidebarState.ts new file mode 100644 index 000000000..9caa0b451 --- /dev/null +++ b/surfsense_web/components/layout/hooks/useSidebarState.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +const SIDEBAR_COOKIE_NAME = "sidebar_collapsed"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year + +interface UseSidebarStateReturn { + isCollapsed: boolean; + setIsCollapsed: (collapsed: boolean) => void; + toggleCollapsed: () => void; +} + +export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn { + const [isCollapsed, setIsCollapsedState] = useState(defaultCollapsed); + + // Initialize from cookie on mount + useEffect(() => { + try { + const match = document.cookie.match(/(?:^|; )sidebar_collapsed=([^;]+)/); + if (match) { + setIsCollapsedState(match[1] === "true"); + } + } catch { + // Ignore cookie read errors + } + }, []); + + // Persist to cookie when state changes + const setIsCollapsed = useCallback((collapsed: boolean) => { + setIsCollapsedState(collapsed); + try { + document.cookie = `${SIDEBAR_COOKIE_NAME}=${collapsed}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + } catch { + // Ignore cookie write errors + } + }, []); + + const toggleCollapsed = useCallback(() => { + setIsCollapsed(!isCollapsed); + }, [isCollapsed, setIsCollapsed]); + + // Keyboard shortcut: Cmd/Ctrl + B + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "b" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleCollapsed(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleCollapsed]); + + return { + isCollapsed, + setIsCollapsed, + toggleCollapsed, + }; +} diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts new file mode 100644 index 000000000..745075b6f --- /dev/null +++ b/surfsense_web/components/layout/index.ts @@ -0,0 +1,30 @@ +export { useSidebarState } from "./hooks"; +export { LayoutDataProvider } from "./providers"; +export type { + ChatItem, + IconRailProps, + NavItem, + NoteItem, + PageUsage, + SidebarSectionProps, + User, + Workspace, +} from "./types/layout.types"; +export { + ChatListItem, + Header, + IconRail, + LayoutShell, + MobileSidebar, + MobileSidebarTrigger, + NavIcon, + NavSection, + NoteListItem, + PageUsageDisplay, + Sidebar, + SidebarCollapseButton, + SidebarHeader, + SidebarSection, + SidebarUserProfile, + WorkspaceAvatar, +} from "./ui"; diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx new file mode 100644 index 000000000..ea750a365 --- /dev/null +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -0,0 +1,486 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAtomValue, useSetAtom } from "jotai"; +import { Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useTheme } from "next-themes"; +import { useCallback, useMemo, useState } from "react"; +import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; +import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useLogsSummary } from "@/hooks/use-logs"; +import { notesApiService } from "@/lib/apis/notes-api.service"; +import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; +import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; +import { resetUser, trackLogout } from "@/lib/posthog/events"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import type { ChatItem, NavItem, NoteItem, Workspace } from "../types/layout.types"; +import { LayoutShell } from "../ui/shell"; +import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar"; +import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar"; + +interface LayoutDataProviderProps { + searchSpaceId: string; + children: React.ReactNode; + breadcrumb?: React.ReactNode; + languageSwitcher?: React.ReactNode; +} + +export function LayoutDataProvider({ + searchSpaceId, + children, + breadcrumb, + languageSwitcher, +}: LayoutDataProviderProps) { + const t = useTranslations("dashboard"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const params = useParams(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + const { theme, setTheme } = useTheme(); + + // Atoms + const { data: user } = useAtomValue(currentUserAtom); + const { data: searchSpacesData } = useAtomValue(searchSpacesAtom); + const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom); + const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom); + + // Current IDs from URL + const currentChatId = params?.chat_id + ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) + : null; + const currentNoteId = params?.note_id + ? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id) + : null; + + // Fetch current search space + const { data: searchSpace } = useQuery({ + queryKey: cacheKeys.searchSpaces.detail(searchSpaceId), + queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), + enabled: !!searchSpaceId, + }); + + // Fetch threads + const { data: threadsData, refetch: refetchThreads } = useQuery({ + queryKey: ["threads", searchSpaceId, { limit: 4 }], + queryFn: () => fetchThreads(Number(searchSpaceId), 4), + enabled: !!searchSpaceId, + }); + + // Fetch notes + const { data: notesData, refetch: refetchNotes } = useQuery({ + queryKey: ["notes", searchSpaceId], + queryFn: () => + notesApiService.getNotes({ + search_space_id: Number(searchSpaceId), + page_size: 4, + }), + enabled: !!searchSpaceId, + }); + + // Poll for active reindexing tasks to show inline loading indicators + const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, { + enablePolling: true, + refetchInterval: 5000, + }); + + // Create a Set of document IDs that are currently being reindexed + const reindexingDocumentIds = useMemo(() => { + if (!summary?.active_tasks) return new Set(); + return new Set( + summary.active_tasks + .filter((task) => task.document_id != null) + .map((task) => task.document_id as number) + ); + }, [summary?.active_tasks]); + + // All chats/notes sidebars state + const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false); + const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); + + // Delete dialogs state + const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); + const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); + const [isDeletingChat, setIsDeletingChat] = useState(false); + + const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false); + const [noteToDelete, setNoteToDelete] = useState<{ + id: number; + name: string; + search_space_id: number; + } | null>(null); + const [isDeletingNote, setIsDeletingNote] = useState(false); + + // Transform workspaces (API returns array directly, not { items: [...] }) + const workspaces: Workspace[] = useMemo(() => { + if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; + return searchSpacesData.map((space) => ({ + id: space.id, + name: space.name, + description: space.description, + isOwner: space.is_owner, + memberCount: space.member_count || 0, + })); + }, [searchSpacesData]); + + // Use searchSpace query result for current workspace (more reliable than finding in list) + const activeWorkspace: Workspace | null = searchSpace + ? { + id: searchSpace.id, + name: searchSpace.name, + description: searchSpace.description, + isOwner: searchSpace.is_owner, + memberCount: searchSpace.member_count || 0, + } + : null; + + // Transform chats + const chats: ChatItem[] = useMemo(() => { + if (!threadsData?.threads) return []; + return threadsData.threads.map((thread) => ({ + id: thread.id, + name: thread.title || `Chat ${thread.id}`, + url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`, + })); + }, [threadsData, searchSpaceId]); + + // Transform notes + const notes: NoteItem[] = useMemo(() => { + if (!notesData?.items) return []; + const sortedNotes = [...notesData.items].sort((a, b) => { + const dateA = a.updated_at + ? new Date(a.updated_at).getTime() + : new Date(a.created_at).getTime(); + const dateB = b.updated_at + ? new Date(b.updated_at).getTime() + : new Date(b.created_at).getTime(); + return dateB - dateA; + }); + return sortedNotes.slice(0, 4).map((note) => ({ + id: note.id, + name: note.title, + url: `/dashboard/${note.search_space_id}/editor/${note.id}`, + isReindexing: reindexingDocumentIds.has(note.id), + })); + }, [notesData, reindexingDocumentIds]); + + // Navigation items + const navItems: NavItem[] = useMemo( + () => [ + { + title: "Documents", + url: `/dashboard/${searchSpaceId}/documents`, + icon: SquareLibrary, + isActive: pathname?.includes("/documents"), + }, + { + title: "Logs", + url: `/dashboard/${searchSpaceId}/logs`, + icon: Logs, + isActive: pathname?.includes("/logs"), + }, + ], + [searchSpaceId, pathname] + ); + + // Handlers + const handleWorkspaceSelect = useCallback( + (id: number) => { + router.push(`/dashboard/${id}/new-chat`); + }, + [router] + ); + + const handleAddWorkspace = useCallback(() => { + router.push("/dashboard/searchspaces"); + }, [router]); + + const handleSeeAllWorkspaces = useCallback(() => { + router.push("/dashboard"); + }, [router]); + + const handleNavItemClick = useCallback( + (item: NavItem) => { + router.push(item.url); + }, + [router] + ); + + const handleNewChat = useCallback(() => { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + }, [router, searchSpaceId]); + + const handleChatSelect = useCallback( + (chat: ChatItem) => { + router.push(chat.url); + }, + [router] + ); + + const handleChatDelete = useCallback((chat: ChatItem) => { + setChatToDelete({ id: chat.id, name: chat.name }); + setShowDeleteChatDialog(true); + }, []); + + const handleNoteSelect = useCallback( + (note: NoteItem) => { + if (hasUnsavedEditorChanges) { + setPendingNavigation(note.url); + } else { + router.push(note.url); + } + }, + [router, hasUnsavedEditorChanges, setPendingNavigation] + ); + + const handleNoteDelete = useCallback( + (note: NoteItem) => { + setNoteToDelete({ id: note.id, name: note.name, search_space_id: Number(searchSpaceId) }); + setShowDeleteNoteDialog(true); + }, + [searchSpaceId] + ); + + const handleAddNote = useCallback(() => { + const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`; + if (hasUnsavedEditorChanges) { + setPendingNavigation(newNoteUrl); + } else { + router.push(newNoteUrl); + } + }, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]); + + const handleSettings = useCallback(() => { + router.push(`/dashboard/${searchSpaceId}/settings`); + }, [router, searchSpaceId]); + + const handleInviteMembers = useCallback(() => { + router.push(`/dashboard/${searchSpaceId}/team`); + }, [router, searchSpaceId]); + + const handleLogout = useCallback(() => { + try { + trackLogout(); + resetUser(); + if (typeof window !== "undefined") { + localStorage.removeItem("surfsense_bearer_token"); + router.push("/"); + } + } catch (error) { + console.error("Error during logout:", error); + router.push("/"); + } + }, [router]); + + const handleToggleTheme = useCallback(() => { + setTheme(theme === "dark" ? "light" : "dark"); + }, [theme, setTheme]); + + const handleViewAllChats = useCallback(() => { + setIsAllChatsSidebarOpen(true); + }, []); + + const handleViewAllNotes = useCallback(() => { + setIsAllNotesSidebarOpen(true); + }, []); + + // Delete handlers + const confirmDeleteChat = useCallback(async () => { + if (!chatToDelete) return; + setIsDeletingChat(true); + try { + await deleteThread(chatToDelete.id); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + if (currentChatId === chatToDelete.id) { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + } + } catch (error) { + console.error("Error deleting thread:", error); + } finally { + setIsDeletingChat(false); + setShowDeleteChatDialog(false); + setChatToDelete(null); + } + }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); + + const confirmDeleteNote = useCallback(async () => { + if (!noteToDelete) return; + setIsDeletingNote(true); + try { + await notesApiService.deleteNote({ + search_space_id: noteToDelete.search_space_id, + note_id: noteToDelete.id, + }); + refetchNotes(); + } catch (error) { + console.error("Error deleting note:", error); + } finally { + setIsDeletingNote(false); + setShowDeleteNoteDialog(false); + setNoteToDelete(null); + } + }, [noteToDelete, refetchNotes]); + + // Page usage + const pageUsage = user + ? { + pagesUsed: user.pages_used, + pagesLimit: user.pages_limit, + } + : undefined; + + // Detect if we're on the chat page (needs overflow-hidden for chat's own scroll) + const isChatPage = pathname?.includes("/new-chat") ?? false; + + return ( + <> + + {children} + + + {/* Delete Chat Dialog */} + + + + + + {t("delete_chat")} + + + {t("delete_chat_confirm")} {chatToDelete?.name}?{" "} + {t("action_cannot_undone")} + + + + + + + + + + {/* All Chats Sidebar */} + + + {/* All Notes Sidebar */} + + + {/* Delete Note Dialog */} + + + + + + {t("delete_note")} + + + {t("delete_note_confirm")} {noteToDelete?.name}?{" "} + {t("action_cannot_undone")} + + + + + + + + + + ); +} diff --git a/surfsense_web/components/layout/providers/index.ts b/surfsense_web/components/layout/providers/index.ts new file mode 100644 index 000000000..61ea094de --- /dev/null +++ b/surfsense_web/components/layout/providers/index.ts @@ -0,0 +1 @@ +export { LayoutDataProvider } from "./LayoutDataProvider"; diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts new file mode 100644 index 000000000..b11619c60 --- /dev/null +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -0,0 +1,139 @@ +import type { LucideIcon } from "lucide-react"; + +export interface Workspace { + id: number; + name: string; + description?: string | null; + isOwner: boolean; + memberCount: number; +} + +export interface User { + email: string; + name?: string; +} + +export interface NavItem { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + badge?: string | number; +} + +export interface ChatItem { + id: number; + name: string; + url: string; + isActive?: boolean; +} + +export interface NoteItem { + id: number; + name: string; + url: string; + isActive?: boolean; + isReindexing?: boolean; +} + +export interface PageUsage { + pagesUsed: number; + pagesLimit: number; +} + +export interface IconRailProps { + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + className?: string; +} + +export interface SidebarHeaderProps { + workspace: Workspace | null; + onSettings?: () => void; +} + +export interface SidebarSectionProps { + title: string; + defaultOpen?: boolean; + children: React.ReactNode; + action?: React.ReactNode; +} + +export interface NavSectionProps { + items: NavItem[]; + onItemClick?: (item: NavItem) => void; +} + +export interface ChatsSectionProps { + chats: ChatItem[]; + activeChatId?: number | null; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + searchSpaceId?: string; +} + +export interface NotesSectionProps { + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + searchSpaceId?: string; +} + +export interface PageUsageDisplayProps { + pagesUsed: number; + pagesLimit: number; +} + +export interface SidebarUserProfileProps { + user: User; + searchSpaceId?: string; + onSettings?: () => void; + onInviteMembers?: () => void; + onSwitchWorkspace?: () => void; + onToggleTheme?: () => void; + onLogout?: () => void; + theme?: string; +} + +export interface SidebarProps { + workspace: Workspace | null; + searchSpaceId?: string; + navItems: NavItem[]; + chats: ChatItem[]; + activeChatId?: number | null; + onNewChat: () => void; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + user: User; + theme?: string; + onSettings?: () => void; + onInviteMembers?: () => void; + onSwitchWorkspace?: () => void; + onToggleTheme?: () => void; + onLogout?: () => void; + pageUsage?: PageUsage; + className?: string; +} + +export interface LayoutShellProps { + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + sidebarProps: Omit; + children: React.ReactNode; + className?: string; +} diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx new file mode 100644 index 000000000..a03761ef5 --- /dev/null +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface HeaderProps { + breadcrumb?: React.ReactNode; + languageSwitcher?: React.ReactNode; + theme?: string; + onToggleTheme?: () => void; + mobileMenuTrigger?: React.ReactNode; +} + +export function Header({ + breadcrumb, + languageSwitcher, + theme, + onToggleTheme, + mobileMenuTrigger, +}: HeaderProps) { + return ( +
+ {/* Left side - Mobile menu trigger + Breadcrumb */} +
+ {mobileMenuTrigger} + {breadcrumb} +
+ + {/* Right side - Actions */} +
+ {/* Theme toggle */} + {onToggleTheme && ( + + + + + {theme === "dark" ? "Light mode" : "Dark mode"} + + )} + + {languageSwitcher} +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/header/index.ts b/surfsense_web/components/layout/ui/header/index.ts new file mode 100644 index 000000000..c940126c9 --- /dev/null +++ b/surfsense_web/components/layout/ui/header/index.ts @@ -0,0 +1 @@ +export { Header } from "./Header"; diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx new file mode 100644 index 000000000..0d6b39cdc --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { Workspace } from "../../types/layout.types"; +import { WorkspaceAvatar } from "./WorkspaceAvatar"; + +interface IconRailProps { + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + className?: string; +} + +export function IconRail({ + workspaces, + activeWorkspaceId, + onWorkspaceSelect, + onAddWorkspace, + className, +}: IconRailProps) { + return ( +
+ +
+ {workspaces.map((workspace) => ( + onWorkspaceSelect(workspace.id)} + size="md" + /> + ))} + + + + + + + Add workspace + + +
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx b/surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx new file mode 100644 index 000000000..3efb48748 --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface NavIconProps { + icon: LucideIcon; + label: string; + isActive?: boolean; + onClick?: () => void; +} + +export function NavIcon({ icon: Icon, label, isActive, onClick }: NavIconProps) { + return ( + + + + + + {label} + + + ); +} diff --git a/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx new file mode 100644 index 000000000..1c4798d2a --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface WorkspaceAvatarProps { + name: string; + isActive?: boolean; + onClick?: () => void; + size?: "sm" | "md"; +} + +/** + * Generates a consistent color based on workspace name + */ +function stringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const colors = [ + "#6366f1", // indigo + "#22c55e", // green + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // violet + "#06b6d4", // cyan + "#ec4899", // pink + "#14b8a6", // teal + ]; + return colors[Math.abs(hash) % colors.length]; +} + +/** + * Gets initials from workspace name (max 2 chars) + */ +function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + +export function WorkspaceAvatar({ name, isActive, onClick, size = "md" }: WorkspaceAvatarProps) { + const bgColor = stringToColor(name); + const initials = getInitials(name); + const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; + + return ( + + + + + + {name} + + + ); +} diff --git a/surfsense_web/components/layout/ui/icon-rail/index.ts b/surfsense_web/components/layout/ui/icon-rail/index.ts new file mode 100644 index 000000000..0e7e8cd29 --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/index.ts @@ -0,0 +1,3 @@ +export { IconRail } from "./IconRail"; +export { NavIcon } from "./NavIcon"; +export { WorkspaceAvatar } from "./WorkspaceAvatar"; diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts new file mode 100644 index 000000000..74b1e9240 --- /dev/null +++ b/surfsense_web/components/layout/ui/index.ts @@ -0,0 +1,16 @@ +export { Header } from "./header"; +export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail"; +export { LayoutShell } from "./shell"; +export { + ChatListItem, + MobileSidebar, + MobileSidebarTrigger, + NavSection, + NoteListItem, + PageUsageDisplay, + Sidebar, + SidebarCollapseButton, + SidebarHeader, + SidebarSection, + SidebarUserProfile, +} from "./sidebar"; diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx new file mode 100644 index 000000000..0d7b24113 --- /dev/null +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; +import { useSidebarState } from "../../hooks"; +import type { + ChatItem, + NavItem, + NoteItem, + PageUsage, + User, + Workspace, +} from "../../types/layout.types"; +import { Header } from "../header"; +import { IconRail } from "../icon-rail"; +import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; + +interface LayoutShellProps { + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + workspace: Workspace | null; + navItems: NavItem[]; + onNavItemClick?: (item: NavItem) => void; + chats: ChatItem[]; + activeChatId?: number | null; + onNewChat: () => void; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + user: User; + onSettings?: () => void; + onInviteMembers?: () => void; + onSeeAllWorkspaces?: () => void; + onLogout?: () => void; + pageUsage?: PageUsage; + breadcrumb?: React.ReactNode; + languageSwitcher?: React.ReactNode; + theme?: string; + onToggleTheme?: () => void; + defaultCollapsed?: boolean; + isChatPage?: boolean; + children: React.ReactNode; + className?: string; +} + +export function LayoutShell({ + workspaces, + activeWorkspaceId, + onWorkspaceSelect, + onAddWorkspace, + workspace, + navItems, + onNavItemClick, + chats, + activeChatId, + onNewChat, + onChatSelect, + onChatDelete, + onViewAllChats, + notes, + activeNoteId, + onNoteSelect, + onNoteDelete, + onAddNote, + onViewAllNotes, + user, + onSettings, + onInviteMembers, + onSeeAllWorkspaces, + onLogout, + pageUsage, + breadcrumb, + languageSwitcher, + theme, + onToggleTheme, + defaultCollapsed = false, + isChatPage = false, + children, + className, +}: LayoutShellProps) { + const isMobile = useIsMobile(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const { isCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); + + // Mobile layout + if (isMobile) { + return ( + +
+
setMobileMenuOpen(true)} />} + /> + + + +
+ {children} +
+
+
+ ); + } + + // Desktop layout + return ( + +
+
+ +
+ +
+ + +
+
+ +
+ {children} +
+
+
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/shell/index.ts b/surfsense_web/components/layout/ui/shell/index.ts new file mode 100644 index 000000000..d7d96a574 --- /dev/null +++ b/surfsense_web/components/layout/ui/shell/index.ts @@ -0,0 +1 @@ +export { LayoutShell } from "./LayoutShell"; diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx new file mode 100644 index 000000000..02459f2b9 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -0,0 +1,443 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { + ArchiveIcon, + Loader2, + MessageCircleMore, + MoreHorizontal, + RotateCcwIcon, + Search, + Trash2, + X, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { + deleteThread, + fetchThreads, + searchThreads, + type ThreadListItem, + updateThread, +} from "@/lib/chat/thread-persistence"; +import { cn } from "@/lib/utils"; + +interface AllChatsSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; + onCloseMobileSidebar?: () => void; +} + +export function AllChatsSidebar({ + open, + onOpenChange, + searchSpaceId, + onCloseMobileSidebar, +}: AllChatsSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const params = useParams(); + const queryClient = useQueryClient(); + + // Get the current chat ID from URL to check if user is deleting the currently open chat + const currentChatId = Array.isArray(params.chat_id) + ? Number(params.chat_id[0]) + : params.chat_id + ? Number(params.chat_id) + : null; + const [deletingThreadId, setDeletingThreadId] = useState(null); + const [archivingThreadId, setArchivingThreadId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [showArchived, setShowArchived] = useState(false); + const [mounted, setMounted] = useState(false); + const [openDropdownId, setOpenDropdownId] = useState(null); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); + + const isSearchMode = !!debouncedSearchQuery.trim(); + + // Handle mounting for portal + useEffect(() => { + setMounted(true); + }, []); + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + // Lock body scroll when open + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + // Fetch all threads (when not searching) + const { + data: threadsData, + error: threadsError, + isLoading: isLoadingThreads, + } = useQuery({ + queryKey: ["all-threads", searchSpaceId], + queryFn: () => fetchThreads(Number(searchSpaceId)), + enabled: !!searchSpaceId && open && !isSearchMode, + }); + + // Search threads (when searching) + const { + data: searchData, + error: searchError, + isLoading: isLoadingSearch, + } = useQuery({ + queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery], + queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()), + enabled: !!searchSpaceId && open && isSearchMode, + }); + + // Handle thread navigation + const handleThreadClick = useCallback( + (threadId: number) => { + router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`); + onOpenChange(false); + // Also close the main sidebar on mobile + onCloseMobileSidebar?.(); + }, + [router, onOpenChange, searchSpaceId, onCloseMobileSidebar] + ); + + // Handle thread deletion + const handleDeleteThread = useCallback( + async (threadId: number) => { + setDeletingThreadId(threadId); + try { + await deleteThread(threadId); + toast.success(t("chat_deleted") || "Chat deleted successfully"); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + + // If the deleted chat is currently open, close sidebar first then redirect + if (currentChatId === threadId) { + onOpenChange(false); + // Wait for sidebar close animation to complete before navigating + setTimeout(() => { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + }, 250); + } + } catch (error) { + console.error("Error deleting thread:", error); + toast.error(t("error_deleting_chat") || "Failed to delete chat"); + } finally { + setDeletingThreadId(null); + } + }, + [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] + ); + + // Handle thread archive/unarchive + const handleToggleArchive = useCallback( + async (threadId: number, currentlyArchived: boolean) => { + setArchivingThreadId(threadId); + try { + await updateThread(threadId, { archived: !currentlyArchived }); + toast.success( + currentlyArchived + ? t("chat_unarchived") || "Chat restored" + : t("chat_archived") || "Chat archived" + ); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + } catch (error) { + console.error("Error archiving thread:", error); + toast.error(t("error_archiving_chat") || "Failed to archive chat"); + } finally { + setArchivingThreadId(null); + } + }, + [queryClient, searchSpaceId, t] + ); + + // Clear search + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); + + // Determine which data source to use + let threads: ThreadListItem[] = []; + if (isSearchMode) { + threads = searchData ?? []; + } else if (threadsData) { + threads = showArchived ? threadsData.archived_threads : threadsData.threads; + } + + const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; + const error = isSearchMode ? searchError : threadsError; + + // Get counts for tabs + const activeCount = threadsData?.threads.length ?? 0; + const archivedCount = threadsData?.archived_threads.length ?? 0; + + if (!mounted) return null; + + return createPortal( + + {open && ( + <> + {/* Backdrop */} + onOpenChange(false)} + aria-hidden="true" + /> + + {/* Panel */} + + {/* Header */} +
+
+

{t("all_chats") || "All Chats"}

+ +
+ + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
+ + {/* Tab toggle for active/archived (only show when not searching) */} + {!isSearchMode && ( +
+ + +
+ )} + + {/* Scrollable Content */} +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ {t("error_loading_chats") || "Error loading chats"} +
+ ) : threads.length > 0 ? ( +
+ {threads.map((thread) => { + const isDeleting = deletingThreadId === thread.id; + const isArchiving = archivingThreadId === thread.id; + const isBusy = isDeleting || isArchiving; + const isActive = currentChatId === thread.id; + + return ( +
+ {/* Main clickable area for navigation */} + + + + + +

+ {t("updated") || "Updated"}:{" "} + {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")} +

+
+
+ + {/* Actions dropdown */} + setOpenDropdownId(isOpen ? thread.id : null)} + > + + + + + handleToggleArchive(thread.id, thread.archived)} + disabled={isArchiving} + > + {thread.archived ? ( + <> + + {t("unarchive") || "Restore"} + + ) : ( + <> + + {t("archive") || "Archive"} + + )} + + + handleDeleteThread(thread.id)} + className="text-destructive focus:text-destructive" + > + + {t("delete") || "Delete"} + + + +
+ ); + })} +
+ ) : isSearchMode ? ( +
+ +

+ {t("no_chats_found") || "No chats found"} +

+

+ {t("try_different_search") || "Try a different search term"} +

+
+ ) : ( +
+ +

+ {showArchived + ? t("no_archived_chats") || "No archived chats" + : t("no_chats") || "No chats yet"} +

+ {!showArchived && ( +

+ {t("start_new_chat_hint") || "Start a new chat from the chat page"} +

+ )} +
+ )} +
+
+ + )} +
, + document.body + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx new file mode 100644 index 000000000..67d1b4ba6 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { documentsApiService } from "@/lib/apis/documents-api.service"; +import { notesApiService } from "@/lib/apis/notes-api.service"; +import { cn } from "@/lib/utils"; + +interface AllNotesSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; + onAddNote?: () => void; + onCloseMobileSidebar?: () => void; +} + +export function AllNotesSidebar({ + open, + onOpenChange, + searchSpaceId, + onAddNote, + onCloseMobileSidebar, +}: AllNotesSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const params = useParams(); + const queryClient = useQueryClient(); + + // Get the current note ID from URL to highlight the open note + const currentNoteId = params.note_id ? Number(params.note_id) : null; + const [deletingNoteId, setDeletingNoteId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [mounted, setMounted] = useState(false); + const [openDropdownId, setOpenDropdownId] = useState(null); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); + + // Handle mounting for portal + useEffect(() => { + setMounted(true); + }, []); + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + // Lock body scroll when open + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + // Fetch all notes (when no search query) + const { + data: notesData, + error: notesError, + isLoading: isLoadingNotes, + } = useQuery({ + queryKey: ["all-notes", searchSpaceId], + queryFn: () => + notesApiService.getNotes({ + search_space_id: Number(searchSpaceId), + page_size: 1000, + }), + enabled: !!searchSpaceId && open && !debouncedSearchQuery, + }); + + // Search notes (when there's a search query) + const { + data: searchData, + error: searchError, + isLoading: isSearching, + } = useQuery({ + queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery], + queryFn: () => + documentsApiService.searchDocuments({ + queryParams: { + search_space_id: Number(searchSpaceId), + document_types: ["NOTE"], + title: debouncedSearchQuery, + page_size: 100, + }, + }), + enabled: !!searchSpaceId && open && !!debouncedSearchQuery, + }); + + // Handle note navigation + const handleNoteClick = useCallback( + (noteId: number, noteSearchSpaceId: number) => { + router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`); + onOpenChange(false); + // Also close the main sidebar on mobile + onCloseMobileSidebar?.(); + }, + [router, onOpenChange, onCloseMobileSidebar] + ); + + // Handle note deletion + const handleDeleteNote = useCallback( + async (noteId: number, noteSearchSpaceId: number) => { + setDeletingNoteId(noteId); + try { + await notesApiService.deleteNote({ + search_space_id: noteSearchSpaceId, + note_id: noteId, + }); + queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] }); + } catch (error) { + console.error("Error deleting note:", error); + } finally { + setDeletingNoteId(null); + } + }, + [queryClient, searchSpaceId] + ); + + // Clear search + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); + + // Determine which data to show + const isSearchMode = !!debouncedSearchQuery; + const isLoading = isSearchMode ? isSearching : isLoadingNotes; + const error = isSearchMode ? searchError : notesError; + + // Transform and sort notes data - handle both regular notes and search results + const notes = useMemo(() => { + let notesList: { + id: number; + title: string; + search_space_id: number; + created_at: string; + updated_at?: string | null; + }[]; + + if (isSearchMode && searchData?.items) { + notesList = searchData.items.map((doc) => ({ + id: doc.id, + title: doc.title, + search_space_id: doc.search_space_id, + created_at: doc.created_at, + updated_at: doc.updated_at, + })); + } else { + notesList = notesData?.items ?? []; + } + + // Sort notes by updated_at (most recent first), fallback to created_at + return [...notesList].sort((a, b) => { + const dateA = a.updated_at + ? new Date(a.updated_at).getTime() + : new Date(a.created_at).getTime(); + const dateB = b.updated_at + ? new Date(b.updated_at).getTime() + : new Date(b.created_at).getTime(); + return dateB - dateA; // Descending order (most recent first) + }); + }, [isSearchMode, searchData, notesData]); + + if (!mounted) return null; + + return createPortal( + + {open && ( + <> + {/* Backdrop */} + onOpenChange(false)} + aria-hidden="true" + /> + + {/* Panel */} + + {/* Header */} +
+
+

{t("all_notes") || "All Notes"}

+ +
+ + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
+ + {/* Scrollable Content */} +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ {t("error_loading_notes") || "Error loading notes"} +
+ ) : notes.length > 0 ? ( +
+ {notes.map((note) => { + const isDeleting = deletingNoteId === note.id; + const isActive = currentNoteId === note.id; + + return ( +
+ {/* Main clickable area for navigation */} + + + + + +
+

+ {t("created") || "Created"}:{" "} + {format(new Date(note.created_at), "MMM d, yyyy 'at' h:mm a")} +

+ {note.updated_at && ( +

+ {t("updated") || "Updated"}:{" "} + {format(new Date(note.updated_at), "MMM d, yyyy 'at' h:mm a")} +

+ )} +
+
+
+ + {/* Actions dropdown - separate from main click area */} + setOpenDropdownId(isOpen ? note.id : null)} + > + + + + + handleDeleteNote(note.id, note.search_space_id)} + className="text-destructive focus:text-destructive" + > + + {t("delete") || "Delete"} + + + +
+ ); + })} +
+ ) : isSearchMode ? ( +
+ +

+ {t("no_results_found") || "No notes found"} +

+

+ {t("try_different_search") || "Try a different search term"} +

+
+ ) : ( +
+ +

+ {t("no_notes") || "No notes yet"} +

+ {onAddNote && ( + + )} +
+ )} +
+ + {/* Footer with Add Note button */} + {onAddNote && notes.length > 0 && ( +
+ +
+ )} +
+ + )} +
, + document.body + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx new file mode 100644 index 000000000..7f5ede04c --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { MessageSquare, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +interface ChatListItemProps { + name: string; + isActive?: boolean; + onClick?: () => void; + onDelete?: () => void; +} + +export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) { + const t = useTranslations("sidebar"); + + return ( +
+ + + {/* Actions dropdown */} +
+ + + + + + { + e.stopPropagation(); + onDelete?.(); + }} + className="text-destructive focus:text-destructive" + > + {t("delete")} + + + +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx new file mode 100644 index 000000000..8429d6671 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { Menu } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import type { + ChatItem, + NavItem, + NoteItem, + PageUsage, + User, + Workspace, +} from "../../types/layout.types"; +import { IconRail } from "../icon-rail"; +import { Sidebar } from "./Sidebar"; + +interface MobileSidebarProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + workspace: Workspace | null; + navItems: NavItem[]; + onNavItemClick?: (item: NavItem) => void; + chats: ChatItem[]; + activeChatId?: number | null; + onNewChat: () => void; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + user: User; + onSettings?: () => void; + onInviteMembers?: () => void; + onSeeAllWorkspaces?: () => void; + onLogout?: () => void; + pageUsage?: PageUsage; +} + +export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +export function MobileSidebar({ + isOpen, + onOpenChange, + workspaces, + activeWorkspaceId, + onWorkspaceSelect, + onAddWorkspace, + workspace, + navItems, + onNavItemClick, + chats, + activeChatId, + onNewChat, + onChatSelect, + onChatDelete, + onViewAllChats, + notes, + activeNoteId, + onNoteSelect, + onNoteDelete, + onAddNote, + onViewAllNotes, + user, + onSettings, + onInviteMembers, + onSeeAllWorkspaces, + onLogout, + pageUsage, +}: MobileSidebarProps) { + const handleWorkspaceSelect = (id: number) => { + onWorkspaceSelect(id); + }; + + const handleNavItemClick = (item: NavItem) => { + onNavItemClick?.(item); + onOpenChange(false); + }; + + const handleChatSelect = (chat: ChatItem) => { + onChatSelect(chat); + onOpenChange(false); + }; + + const handleNoteSelect = (note: NoteItem) => { + onNoteSelect(note); + onOpenChange(false); + }; + + return ( + + + Navigation + +
+ + + +
+ +
+ { + onNewChat(); + onOpenChange(false); + }} + onChatSelect={handleChatSelect} + onChatDelete={onChatDelete} + onViewAllChats={onViewAllChats} + notes={notes} + activeNoteId={activeNoteId} + onNoteSelect={handleNoteSelect} + onNoteDelete={onNoteDelete} + onAddNote={onAddNote} + onViewAllNotes={onViewAllNotes} + user={user} + onSettings={onSettings} + onInviteMembers={onInviteMembers} + onSeeAllWorkspaces={onSeeAllWorkspaces} + onLogout={onLogout} + pageUsage={pageUsage} + className="w-full border-none" + /> +
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx new file mode 100644 index 000000000..7b694055b --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { NavItem } from "../../types/layout.types"; + +interface NavSectionProps { + items: NavItem[]; + onItemClick?: (item: NavItem) => void; + isCollapsed?: boolean; +} + +export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) { + return ( +
+ {items.map((item) => { + const Icon = item.icon; + + // Add data-joyride for onboarding tour + const joyrideAttr = + item.title === "Documents" || item.title.toLowerCase().includes("documents") + ? { "data-joyride": "documents-sidebar" } + : {}; + + if (isCollapsed) { + return ( + + + + + + {item.title} + {item.badge && ` (${item.badge})`} + + + ); + } + + return ( + + ); + })} +
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx b/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx new file mode 100644 index 000000000..0491ebcca --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { FileText, Loader2, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +interface NoteListItemProps { + name: string; + isActive?: boolean; + isReindexing?: boolean; + onClick?: () => void; + onDelete?: () => void; +} + +export function NoteListItem({ + name, + isActive, + isReindexing, + onClick, + onDelete, +}: NoteListItemProps) { + const t = useTranslations("sidebar"); + + return ( +
+ + + {/* Actions dropdown */} +
+ + + + + + { + e.stopPropagation(); + onDelete?.(); + }} + className="text-destructive focus:text-destructive" + > + {t("delete")} + + + +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx new file mode 100644 index 000000000..85abae19b --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Mail } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; + +interface PageUsageDisplayProps { + pagesUsed: number; + pagesLimit: number; +} + +export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { + const usagePercentage = (pagesUsed / pagesLimit) * 100; + + return ( +
+
+
+ + {pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages + + {usagePercentage.toFixed(0)}% +
+ + + + Contact to increase limits + +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx new file mode 100644 index 000000000..5031b08b5 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { + ChatItem, + NavItem, + NoteItem, + PageUsage, + User, + Workspace, +} from "../../types/layout.types"; +import { ChatListItem } from "./ChatListItem"; +import { NavSection } from "./NavSection"; +import { NoteListItem } from "./NoteListItem"; +import { PageUsageDisplay } from "./PageUsageDisplay"; +import { SidebarCollapseButton } from "./SidebarCollapseButton"; +import { SidebarHeader } from "./SidebarHeader"; +import { SidebarSection } from "./SidebarSection"; +import { SidebarUserProfile } from "./SidebarUserProfile"; + +interface SidebarProps { + workspace: Workspace | null; + isCollapsed?: boolean; + onToggleCollapse?: () => void; + navItems: NavItem[]; + onNavItemClick?: (item: NavItem) => void; + chats: ChatItem[]; + activeChatId?: number | null; + onNewChat: () => void; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + user: User; + onSettings?: () => void; + onInviteMembers?: () => void; + onSeeAllWorkspaces?: () => void; + onLogout?: () => void; + pageUsage?: PageUsage; + className?: string; +} + +export function Sidebar({ + workspace, + isCollapsed = false, + onToggleCollapse, + navItems, + onNavItemClick, + chats, + activeChatId, + onNewChat, + onChatSelect, + onChatDelete, + onViewAllChats, + notes, + activeNoteId, + onNoteSelect, + onNoteDelete, + onAddNote, + onViewAllNotes, + user, + onSettings, + onInviteMembers, + onSeeAllWorkspaces, + onLogout, + pageUsage, + className, +}: SidebarProps) { + const t = useTranslations("sidebar"); + + return ( +
+ {/* Header - workspace name or collapse button when collapsed */} + {isCollapsed ? ( +
+ {})} + /> +
+ ) : ( +
+ +
+ {})} + /> +
+
+ )} + + {/* New chat button */} +
+ {isCollapsed ? ( + + + + + {t("new_chat")} + + ) : ( + + )} +
+ + {/* Platform navigation */} + {navItems.length > 0 && ( + + )} + + {/* Scrollable content */} + + {isCollapsed ? ( +
+ {chats.length > 0 && ( + + + + + + {t("recent_chats")} ({chats.length}) + + + )} + {notes.length > 0 && ( + + + + + + {t("notes")} ({notes.length}) + + + )} +
+ ) : ( +
+ 0 ? ( + + + + + {t("view_all_chats")} + + ) : undefined + } + > + {chats.length > 0 ? ( +
+ {chats.map((chat) => ( + onChatSelect(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))} +
+ ) : ( +

{t("no_recent_chats")}

+ )} +
+ + 0 ? ( + + + + + {t("view_all_notes")} + + ) : undefined + } + persistentAction={ + onAddNote && notes.length > 0 ? ( + + + + + {t("add_note")} + + ) : undefined + } + > + {notes.length > 0 ? ( +
+ {notes.map((note) => ( + onNoteSelect(note)} + onDelete={() => onNoteDelete?.(note)} + /> + ))} +
+ ) : onAddNote ? ( + + ) : ( +

{t("no_notes")}

+ )} +
+
+ )} +
+ + {/* Footer */} +
+ {pageUsage && !isCollapsed && ( + + )} + + +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx new file mode 100644 index 000000000..3eaa87070 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { PanelLeft, PanelLeftClose } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface SidebarCollapseButtonProps { + isCollapsed: boolean; + onToggle: () => void; +} + +export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) { + const t = useTranslations("sidebar"); + + return ( + + + + + + {isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`} + + + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx new file mode 100644 index 000000000..cf15a367e --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { ChevronsUpDown, LayoutGrid, Settings, UserPlus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import type { Workspace } from "../../types/layout.types"; + +interface SidebarHeaderProps { + workspace: Workspace | null; + isCollapsed?: boolean; + onSettings?: () => void; + onInviteMembers?: () => void; + onSeeAllWorkspaces?: () => void; + className?: string; +} + +export function SidebarHeader({ + workspace, + isCollapsed, + onSettings, + onInviteMembers, + onSeeAllWorkspaces, + className, +}: SidebarHeaderProps) { + const t = useTranslations("sidebar"); + + return ( +
+ + + + + + + + {t("invite_members")} + + + + + {t("workspace_settings")} + + + + + {t("see_all_workspaces")} + + + +
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx new file mode 100644 index 000000000..4d161e3fa --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import { useState } from "react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +interface SidebarSectionProps { + title: string; + defaultOpen?: boolean; + children: React.ReactNode; + action?: React.ReactNode; + persistentAction?: React.ReactNode; +} + +export function SidebarSection({ + title, + defaultOpen = true, + children, + action, + persistentAction, +}: SidebarSectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( + +
+ + + {title} + + + {/* Action button - visible on hover (always visible on mobile) */} + {action && ( +
+ {action} +
+ )} + + {/* Persistent action - always visible */} + {persistentAction && ( +
{persistentAction}
+ )} +
+ + +
{children}
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx new file mode 100644 index 000000000..29b35b9a9 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { ChevronUp, LogOut } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { User } from "../../types/layout.types"; + +interface SidebarUserProfileProps { + user: User; + onLogout?: () => void; + isCollapsed?: boolean; +} + +/** + * Generates a consistent color based on email + */ +function stringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const colors = [ + "#6366f1", + "#8b5cf6", + "#a855f7", + "#d946ef", + "#ec4899", + "#f43f5e", + "#ef4444", + "#f97316", + "#eab308", + "#84cc16", + "#22c55e", + "#14b8a6", + "#06b6d4", + "#0ea5e9", + "#3b82f6", + ]; + return colors[Math.abs(hash) % colors.length]; +} + +/** + * Gets initials from email + */ +function getInitials(email: string): string { + const name = email.split("@")[0]; + const parts = name.split(/[._-]/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + +export function SidebarUserProfile({ + user, + onLogout, + isCollapsed = false, +}: SidebarUserProfileProps) { + const t = useTranslations("sidebar"); + const bgColor = stringToColor(user.email); + const initials = getInitials(user.email); + const displayName = user.name || user.email.split("@")[0]; + + // Collapsed view - just show avatar with dropdown + if (isCollapsed) { + return ( +
+ + + + + + + + {displayName} + + + + +
+
+ {initials} +
+
+

{displayName}

+

{user.email}

+
+
+
+ + + + + + {t("logout")} + +
+
+
+ ); + } + + // Expanded view + return ( +
+ + + + + + + +
+
+ {initials} +
+
+

{displayName}

+

{user.email}

+
+
+
+ + + + + + {t("logout")} + +
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts new file mode 100644 index 000000000..d98b45ca5 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -0,0 +1,12 @@ +export { AllChatsSidebar } from "./AllChatsSidebar"; +export { AllNotesSidebar } from "./AllNotesSidebar"; +export { ChatListItem } from "./ChatListItem"; +export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; +export { NavSection } from "./NavSection"; +export { NoteListItem } from "./NoteListItem"; +export { PageUsageDisplay } from "./PageUsageDisplay"; +export { Sidebar } from "./Sidebar"; +export { SidebarCollapseButton } from "./SidebarCollapseButton"; +export { SidebarHeader } from "./SidebarHeader"; +export { SidebarSection } from "./SidebarSection"; +export { SidebarUserProfile } from "./SidebarUserProfile"; From 65a10518d5ffc2e1d88088f926daf1cf55f5f219 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 8 Jan 2026 19:10:53 +0200 Subject: [PATCH 2/5] feat: add sidebar translations --- surfsense_web/messages/en.json | 10 +++++++++- surfsense_web/messages/zh.json | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 6c64e62ba..b803d4b69 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -622,7 +622,15 @@ "chat_archived": "Chat archived", "chat_unarchived": "Chat restored", "no_archived_chats": "No archived chats", - "error_archiving_chat": "Failed to archive chat" + "error_archiving_chat": "Failed to archive chat", + "new_chat": "New chat", + "select_workspace": "Select Workspace", + "invite_members": "Invite members", + "workspace_settings": "Workspace settings", + "see_all_workspaces": "See all search spaces", + "expand_sidebar": "Expand sidebar", + "collapse_sidebar": "Collapse sidebar", + "logout": "Logout" }, "errors": { "something_went_wrong": "Something went wrong", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 67069cf55..fa690bf39 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -616,7 +616,15 @@ "more_options": "更多选项", "clear_search": "清除搜索", "view_all_notes": "查看所有笔记", - "add_note": "添加笔记" + "add_note": "添加笔记", + "new_chat": "新对话", + "select_workspace": "选择工作空间", + "invite_members": "邀请成员", + "workspace_settings": "工作空间设置", + "see_all_workspaces": "查看所有搜索空间", + "expand_sidebar": "展开侧边栏", + "collapse_sidebar": "收起侧边栏", + "logout": "退出登录" }, "errors": { "something_went_wrong": "出错了", From ab052bf1f20d56bb07780227b9ace42ea848b773 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 8 Jan 2026 19:11:07 +0200 Subject: [PATCH 3/5] refactor: use new layout in dashboard --- .../[search_space_id]/client-layout.tsx | 89 +++---------------- 1 file changed, 11 insertions(+), 78 deletions(-) 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 c78cc7762..7b1bb61b0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; @@ -17,22 +17,18 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { LayoutDataProvider } from "@/components/layout"; import { OnboardingTour } from "@/components/onboarding-tour"; -import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; export function DashboardClientLayout({ children, searchSpaceId, - navSecondary, - navMain, }: { children: React.ReactNode; searchSpaceId: string; - navSecondary: any[]; - navMain: any[]; + navSecondary?: any[]; + navMain?: any[]; }) { const t = useTranslations("dashboard"); const router = useRouter(); @@ -59,50 +55,15 @@ export function DashboardClientLayout({ const [isAutoConfiguring, setIsAutoConfiguring] = useState(false); const hasAttemptedAutoConfig = useRef(false); - // Skip onboarding check if we're already on the onboarding page const isOnboardingPage = pathname?.includes("/onboard"); - - // Only owners should see onboarding - invited members use existing config const isOwner = access?.is_owner ?? false; - // Translate navigation items - const tNavMenu = useTranslations("nav_menu"); - const translatedNavMain = useMemo(() => { - return navMain.map((item) => ({ - ...item, - title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")), - items: item.items?.map((subItem: any) => ({ - ...subItem, - title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")), - })), - })); - }, [navMain, tNavMenu]); - - const translatedNavSecondary = useMemo(() => { - return navSecondary.map((item) => ({ - ...item, - title: item.title === "All Search Spaces" ? tNavMenu("all_search_spaces") : item.title, - })); - }, [navSecondary, tNavMenu]); - - const [open, setOpen] = useState(() => { - try { - const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/); - if (match) return match[1] === "true"; - } catch { - // ignore - } - return true; - }); - useEffect(() => { - // Skip check if already on onboarding page if (isOnboardingPage) { setHasCheckedOnboarding(true); return; } - // Wait for all data to load if ( !loading && !accessLoading && @@ -112,19 +73,16 @@ export function DashboardClientLayout({ ) { const onboardingComplete = isOnboardingComplete(); - // If onboarding is complete, nothing to do if (onboardingComplete) { setHasCheckedOnboarding(true); return; } - // Only handle onboarding for owners if (!isOwner) { setHasCheckedOnboarding(true); return; } - // If global configs available, auto-configure without going to onboard page if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) { hasAttemptedAutoConfig.current = true; setIsAutoConfiguring(true); @@ -149,7 +107,6 @@ export function DashboardClientLayout({ setHasCheckedOnboarding(true); } catch (error) { console.error("Auto-configuration failed:", error); - // Fall back to onboard page router.push(`/dashboard/${searchSpaceId}/onboard`); } finally { setIsAutoConfiguring(false); @@ -160,7 +117,6 @@ export function DashboardClientLayout({ return; } - // No global configs - redirect to onboard page router.push(`/dashboard/${searchSpaceId}/onboard`); setHasCheckedOnboarding(true); } @@ -180,7 +136,6 @@ export function DashboardClientLayout({ refetchPreferences, ]); - // Synchronize active search space and chat IDs with URL useEffect(() => { const activeSeacrhSpaceId = typeof search_space_id === "string" @@ -192,7 +147,6 @@ export function DashboardClientLayout({ setActiveSearchSpaceIdState(activeSeacrhSpaceId); }, [search_space_id, setActiveSearchSpaceIdState]); - // Show loading screen while checking onboarding status or auto-configuring if ( (!hasCheckedOnboarding && (loading || accessLoading || globalConfigsLoading) && @@ -220,7 +174,6 @@ export function DashboardClientLayout({ ); } - // Show error screen if there's an error loading preferences (but not on onboarding page) if (error && !hasCheckedOnboarding && !isOnboardingPage) { return (
@@ -244,33 +197,13 @@ export function DashboardClientLayout({ return ( - - {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} - - -
-
-
-
- -
- - -
-
-
- -
-
-
-
{children}
-
-
-
+ } + languageSwitcher={} + > + {children} +
); } From 16c94d1c47f9a672c95a5cf62c39403cb4c845a3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 8 Jan 2026 19:11:18 +0200 Subject: [PATCH 4/5] chore: remove old sidebar components --- .../components/sidebar/AppSidebarProvider.tsx | 383 -------------- .../components/sidebar/all-chats-sidebar.tsx | 443 ---------------- .../components/sidebar/all-notes-sidebar.tsx | 407 --------------- .../components/sidebar/app-sidebar.tsx | 473 ------------------ .../components/sidebar/nav-chats.tsx | 237 --------- surfsense_web/components/sidebar/nav-main.tsx | 207 -------- .../components/sidebar/nav-notes.tsx | 287 ----------- .../components/sidebar/nav-secondary.tsx | 59 --- .../components/sidebar/page-usage-display.tsx | 57 --- 9 files changed, 2553 deletions(-) delete mode 100644 surfsense_web/components/sidebar/AppSidebarProvider.tsx delete mode 100644 surfsense_web/components/sidebar/all-chats-sidebar.tsx delete mode 100644 surfsense_web/components/sidebar/all-notes-sidebar.tsx delete mode 100644 surfsense_web/components/sidebar/app-sidebar.tsx delete mode 100644 surfsense_web/components/sidebar/nav-chats.tsx delete mode 100644 surfsense_web/components/sidebar/nav-main.tsx delete mode 100644 surfsense_web/components/sidebar/nav-notes.tsx delete mode 100644 surfsense_web/components/sidebar/nav-secondary.tsx delete mode 100644 surfsense_web/components/sidebar/page-usage-display.tsx diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx deleted file mode 100644 index f5146c427..000000000 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ /dev/null @@ -1,383 +0,0 @@ -"use client"; - -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAtomValue, useSetAtom } from "jotai"; -import { Trash2 } from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; -import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { AppSidebar } from "@/components/sidebar/app-sidebar"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { notesApiService } from "@/lib/apis/notes-api.service"; -import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; - -interface AppSidebarProviderProps { - searchSpaceId: string; - navSecondary: { - title: string; - url: string; - icon: string; - }[]; - navMain: { - title: string; - url: string; - icon: string; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; - }[]; -} - -export function AppSidebarProvider({ - searchSpaceId, - navSecondary, - navMain, -}: AppSidebarProviderProps) { - const t = useTranslations("dashboard"); - const tCommon = useTranslations("common"); - const router = useRouter(); - const params = useParams(); - const queryClient = useQueryClient(); - - // Get current chat ID from URL params - const currentChatId = params?.chat_id - ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) - : null; - const [isDeletingThread, setIsDeletingThread] = useState(false); - - // Editor state for handling unsaved changes - const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom); - const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom); - - // Fetch new chat threads - const { - data: threadsData, - error: threadError, - refetch: refetchThreads, - } = useQuery({ - queryKey: ["threads", searchSpaceId], - queryFn: () => fetchThreads(Number(searchSpaceId), 4), - enabled: !!searchSpaceId, - }); - - const { - data: searchSpace, - isLoading: isLoadingSearchSpace, - error: searchSpaceError, - } = useQuery({ - queryKey: cacheKeys.searchSpaces.detail(searchSpaceId), - queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), - enabled: !!searchSpaceId, - }); - - const { data: user } = useAtomValue(currentUserAtom); - - // Fetch notes - const { data: notesData, refetch: refetchNotes } = useQuery({ - queryKey: ["notes", searchSpaceId], - queryFn: () => - notesApiService.getNotes({ - search_space_id: Number(searchSpaceId), - page_size: 4, // Get 4 notes for compact sidebar - }), - enabled: !!searchSpaceId, - }); - - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [threadToDelete, setThreadToDelete] = useState<{ id: number; name: string } | null>(null); - const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false); - const [noteToDelete, setNoteToDelete] = useState<{ - id: number; - name: string; - search_space_id: number; - } | null>(null); - const [isDeletingNote, setIsDeletingNote] = useState(false); - - // Transform threads to the format expected by AppSidebar - const recentChats = useMemo(() => { - if (!threadsData?.threads) return []; - - // Threads are already sorted by updated_at desc from the API - return threadsData.threads.map((thread) => ({ - name: thread.title || `Chat ${thread.id}`, - url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`, - icon: "MessageCircleMore", - id: thread.id, - search_space_id: Number(searchSpaceId), - actions: [ - { - name: "Delete", - icon: "Trash2", - onClick: () => { - setThreadToDelete({ - id: thread.id, - name: thread.title || `Chat ${thread.id}`, - }); - setShowDeleteDialog(true); - }, - }, - ], - })); - }, [threadsData, searchSpaceId]); - - // Handle delete thread - const handleDeleteThread = useCallback(async () => { - if (!threadToDelete) return; - - setIsDeletingThread(true); - try { - await deleteThread(threadToDelete.id); - // Invalidate threads query to refresh the list - queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); - // Only navigate to new-chat if the deleted chat is currently open - if (currentChatId === threadToDelete.id) { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - } - } catch (error) { - console.error("Error deleting thread:", error); - } finally { - setIsDeletingThread(false); - setShowDeleteDialog(false); - setThreadToDelete(null); - } - }, [threadToDelete, queryClient, searchSpaceId, router, currentChatId]); - - // Handle delete note with confirmation - const handleDeleteNote = useCallback(async () => { - if (!noteToDelete) return; - - setIsDeletingNote(true); - try { - await notesApiService.deleteNote({ - search_space_id: noteToDelete.search_space_id, - note_id: noteToDelete.id, - }); - refetchNotes(); - } catch (error) { - console.error("Error deleting note:", error); - } finally { - setIsDeletingNote(false); - setShowDeleteNoteDialog(false); - setNoteToDelete(null); - } - }, [noteToDelete, refetchNotes]); - - // Memoized fallback chats - const fallbackChats = useMemo(() => { - if (threadError) { - return [ - { - name: t("error_loading_chats"), - url: "#", - icon: "AlertCircle", - id: 0, - search_space_id: Number(searchSpaceId), - actions: [ - { - name: tCommon("retry"), - icon: "RefreshCw", - onClick: () => refetchThreads(), - }, - ], - }, - ]; - } - - return []; - }, [threadError, searchSpaceId, refetchThreads, t, tCommon]); - - // Use fallback chats if there's an error or no chats - const displayChats = recentChats.length > 0 ? recentChats : fallbackChats; - - // Transform notes to the format expected by NavNotes - const recentNotes = useMemo(() => { - if (!notesData?.items) return []; - - // Sort notes by updated_at (most recent first), fallback to created_at if updated_at is null - const sortedNotes = [...notesData.items].sort((a, b) => { - const dateA = a.updated_at - ? new Date(a.updated_at).getTime() - : new Date(a.created_at).getTime(); - const dateB = b.updated_at - ? new Date(b.updated_at).getTime() - : new Date(b.created_at).getTime(); - return dateB - dateA; // Descending order (most recent first) - }); - - // Limit to 4 notes for compact sidebar - return sortedNotes.slice(0, 4).map((note) => ({ - name: note.title, - url: `/dashboard/${note.search_space_id}/editor/${note.id}`, - icon: "FileText", - id: note.id, - search_space_id: note.search_space_id, - actions: [ - { - name: "Delete", - icon: "Trash2", - onClick: () => { - setNoteToDelete({ - id: note.id, - name: note.title, - search_space_id: note.search_space_id, - }); - setShowDeleteNoteDialog(true); - }, - }, - ], - })); - }, [notesData]); - - // Handle add note - check for unsaved changes first - const handleAddNote = useCallback(() => { - const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`; - - if (hasUnsavedEditorChanges) { - // Set pending navigation - the editor will show the unsaved changes dialog - setPendingNavigation(newNoteUrl); - } else { - // No unsaved changes, navigate directly - router.push(newNoteUrl); - } - }, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]); - - // Memoized updated navSecondary - const updatedNavSecondary = useMemo(() => { - const updated = [...navSecondary]; - if (updated.length > 0) { - updated[0] = { - ...updated[0], - title: - searchSpace?.name || - (isLoadingSearchSpace - ? tCommon("loading") - : searchSpaceError - ? t("error_loading_space") - : t("unknown_search_space")), - }; - } - return updated; - }, [navSecondary, searchSpace?.name, isLoadingSearchSpace, searchSpaceError, t, tCommon]); - - // Prepare page usage data - const pageUsage = user - ? { - pagesUsed: user.pages_used, - pagesLimit: user.pages_limit, - } - : undefined; - - return ( - <> - - - {/* Delete Confirmation Dialog */} - - - - - - {t("delete_chat")} - - - {t("delete_chat_confirm")} {threadToDelete?.name} - ? {t("action_cannot_undone")} - - - - - - - - - - {/* Delete Note Confirmation Dialog */} - - - - - - {t("delete_note")} - - - {t("delete_note_confirm")} {noteToDelete?.name}?{" "} - {t("action_cannot_undone")} - - - - - - - - - - ); -} diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/sidebar/all-chats-sidebar.tsx deleted file mode 100644 index 02459f2b9..000000000 --- a/surfsense_web/components/sidebar/all-chats-sidebar.tsx +++ /dev/null @@ -1,443 +0,0 @@ -"use client"; - -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { - ArchiveIcon, - Loader2, - MessageCircleMore, - MoreHorizontal, - RotateCcwIcon, - Search, - Trash2, - X, -} from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useDebouncedValue } from "@/hooks/use-debounced-value"; -import { - deleteThread, - fetchThreads, - searchThreads, - type ThreadListItem, - updateThread, -} from "@/lib/chat/thread-persistence"; -import { cn } from "@/lib/utils"; - -interface AllChatsSidebarProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaceId: string; - onCloseMobileSidebar?: () => void; -} - -export function AllChatsSidebar({ - open, - onOpenChange, - searchSpaceId, - onCloseMobileSidebar, -}: AllChatsSidebarProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const queryClient = useQueryClient(); - - // Get the current chat ID from URL to check if user is deleting the currently open chat - const currentChatId = Array.isArray(params.chat_id) - ? Number(params.chat_id[0]) - : params.chat_id - ? Number(params.chat_id) - : null; - const [deletingThreadId, setDeletingThreadId] = useState(null); - const [archivingThreadId, setArchivingThreadId] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [showArchived, setShowArchived] = useState(false); - const [mounted, setMounted] = useState(false); - const [openDropdownId, setOpenDropdownId] = useState(null); - const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); - - const isSearchMode = !!debouncedSearchQuery.trim(); - - // Handle mounting for portal - useEffect(() => { - setMounted(true); - }, []); - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) { - onOpenChange(false); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - // Lock body scroll when open - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - - // Fetch all threads (when not searching) - const { - data: threadsData, - error: threadsError, - isLoading: isLoadingThreads, - } = useQuery({ - queryKey: ["all-threads", searchSpaceId], - queryFn: () => fetchThreads(Number(searchSpaceId)), - enabled: !!searchSpaceId && open && !isSearchMode, - }); - - // Search threads (when searching) - const { - data: searchData, - error: searchError, - isLoading: isLoadingSearch, - } = useQuery({ - queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery], - queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()), - enabled: !!searchSpaceId && open && isSearchMode, - }); - - // Handle thread navigation - const handleThreadClick = useCallback( - (threadId: number) => { - router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`); - onOpenChange(false); - // Also close the main sidebar on mobile - onCloseMobileSidebar?.(); - }, - [router, onOpenChange, searchSpaceId, onCloseMobileSidebar] - ); - - // Handle thread deletion - const handleDeleteThread = useCallback( - async (threadId: number) => { - setDeletingThreadId(threadId); - try { - await deleteThread(threadId); - toast.success(t("chat_deleted") || "Chat deleted successfully"); - queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); - - // If the deleted chat is currently open, close sidebar first then redirect - if (currentChatId === threadId) { - onOpenChange(false); - // Wait for sidebar close animation to complete before navigating - setTimeout(() => { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - }, 250); - } - } catch (error) { - console.error("Error deleting thread:", error); - toast.error(t("error_deleting_chat") || "Failed to delete chat"); - } finally { - setDeletingThreadId(null); - } - }, - [queryClient, searchSpaceId, t, currentChatId, router, onOpenChange] - ); - - // Handle thread archive/unarchive - const handleToggleArchive = useCallback( - async (threadId: number, currentlyArchived: boolean) => { - setArchivingThreadId(threadId); - try { - await updateThread(threadId, { archived: !currentlyArchived }); - toast.success( - currentlyArchived - ? t("chat_unarchived") || "Chat restored" - : t("chat_archived") || "Chat archived" - ); - queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); - } catch (error) { - console.error("Error archiving thread:", error); - toast.error(t("error_archiving_chat") || "Failed to archive chat"); - } finally { - setArchivingThreadId(null); - } - }, - [queryClient, searchSpaceId, t] - ); - - // Clear search - const handleClearSearch = useCallback(() => { - setSearchQuery(""); - }, []); - - // Determine which data source to use - let threads: ThreadListItem[] = []; - if (isSearchMode) { - threads = searchData ?? []; - } else if (threadsData) { - threads = showArchived ? threadsData.archived_threads : threadsData.threads; - } - - const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; - const error = isSearchMode ? searchError : threadsError; - - // Get counts for tabs - const activeCount = threadsData?.threads.length ?? 0; - const archivedCount = threadsData?.archived_threads.length ?? 0; - - if (!mounted) return null; - - return createPortal( - - {open && ( - <> - {/* Backdrop */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel */} - - {/* Header */} -
-
-

{t("all_chats") || "All Chats"}

- -
- - {/* Search Input */} -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-8 h-9" - /> - {searchQuery && ( - - )} -
-
- - {/* Tab toggle for active/archived (only show when not searching) */} - {!isSearchMode && ( -
- - -
- )} - - {/* Scrollable Content */} -
- {isLoading ? ( -
- -
- ) : error ? ( -
- {t("error_loading_chats") || "Error loading chats"} -
- ) : threads.length > 0 ? ( -
- {threads.map((thread) => { - const isDeleting = deletingThreadId === thread.id; - const isArchiving = archivingThreadId === thread.id; - const isBusy = isDeleting || isArchiving; - const isActive = currentChatId === thread.id; - - return ( -
- {/* Main clickable area for navigation */} - - - - - -

- {t("updated") || "Updated"}:{" "} - {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")} -

-
-
- - {/* Actions dropdown */} - setOpenDropdownId(isOpen ? thread.id : null)} - > - - - - - handleToggleArchive(thread.id, thread.archived)} - disabled={isArchiving} - > - {thread.archived ? ( - <> - - {t("unarchive") || "Restore"} - - ) : ( - <> - - {t("archive") || "Archive"} - - )} - - - handleDeleteThread(thread.id)} - className="text-destructive focus:text-destructive" - > - - {t("delete") || "Delete"} - - - -
- ); - })} -
- ) : isSearchMode ? ( -
- -

- {t("no_chats_found") || "No chats found"} -

-

- {t("try_different_search") || "Try a different search term"} -

-
- ) : ( -
- -

- {showArchived - ? t("no_archived_chats") || "No archived chats" - : t("no_chats") || "No chats yet"} -

- {!showArchived && ( -

- {t("start_new_chat_hint") || "Start a new chat from the chat page"} -

- )} -
- )} -
-
- - )} -
, - document.body - ); -} diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx deleted file mode 100644 index 67d1b4ba6..000000000 --- a/surfsense_web/components/sidebar/all-notes-sidebar.tsx +++ /dev/null @@ -1,407 +0,0 @@ -"use client"; - -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { format } from "date-fns"; -import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useDebouncedValue } from "@/hooks/use-debounced-value"; -import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { notesApiService } from "@/lib/apis/notes-api.service"; -import { cn } from "@/lib/utils"; - -interface AllNotesSidebarProps { - open: boolean; - onOpenChange: (open: boolean) => void; - searchSpaceId: string; - onAddNote?: () => void; - onCloseMobileSidebar?: () => void; -} - -export function AllNotesSidebar({ - open, - onOpenChange, - searchSpaceId, - onAddNote, - onCloseMobileSidebar, -}: AllNotesSidebarProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const queryClient = useQueryClient(); - - // Get the current note ID from URL to highlight the open note - const currentNoteId = params.note_id ? Number(params.note_id) : null; - const [deletingNoteId, setDeletingNoteId] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [mounted, setMounted] = useState(false); - const [openDropdownId, setOpenDropdownId] = useState(null); - const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); - - // Handle mounting for portal - useEffect(() => { - setMounted(true); - }, []); - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && open) { - onOpenChange(false); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, onOpenChange]); - - // Lock body scroll when open - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - - // Fetch all notes (when no search query) - const { - data: notesData, - error: notesError, - isLoading: isLoadingNotes, - } = useQuery({ - queryKey: ["all-notes", searchSpaceId], - queryFn: () => - notesApiService.getNotes({ - search_space_id: Number(searchSpaceId), - page_size: 1000, - }), - enabled: !!searchSpaceId && open && !debouncedSearchQuery, - }); - - // Search notes (when there's a search query) - const { - data: searchData, - error: searchError, - isLoading: isSearching, - } = useQuery({ - queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery], - queryFn: () => - documentsApiService.searchDocuments({ - queryParams: { - search_space_id: Number(searchSpaceId), - document_types: ["NOTE"], - title: debouncedSearchQuery, - page_size: 100, - }, - }), - enabled: !!searchSpaceId && open && !!debouncedSearchQuery, - }); - - // Handle note navigation - const handleNoteClick = useCallback( - (noteId: number, noteSearchSpaceId: number) => { - router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`); - onOpenChange(false); - // Also close the main sidebar on mobile - onCloseMobileSidebar?.(); - }, - [router, onOpenChange, onCloseMobileSidebar] - ); - - // Handle note deletion - const handleDeleteNote = useCallback( - async (noteId: number, noteSearchSpaceId: number) => { - setDeletingNoteId(noteId); - try { - await notesApiService.deleteNote({ - search_space_id: noteSearchSpaceId, - note_id: noteId, - }); - queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] }); - queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] }); - } catch (error) { - console.error("Error deleting note:", error); - } finally { - setDeletingNoteId(null); - } - }, - [queryClient, searchSpaceId] - ); - - // Clear search - const handleClearSearch = useCallback(() => { - setSearchQuery(""); - }, []); - - // Determine which data to show - const isSearchMode = !!debouncedSearchQuery; - const isLoading = isSearchMode ? isSearching : isLoadingNotes; - const error = isSearchMode ? searchError : notesError; - - // Transform and sort notes data - handle both regular notes and search results - const notes = useMemo(() => { - let notesList: { - id: number; - title: string; - search_space_id: number; - created_at: string; - updated_at?: string | null; - }[]; - - if (isSearchMode && searchData?.items) { - notesList = searchData.items.map((doc) => ({ - id: doc.id, - title: doc.title, - search_space_id: doc.search_space_id, - created_at: doc.created_at, - updated_at: doc.updated_at, - })); - } else { - notesList = notesData?.items ?? []; - } - - // Sort notes by updated_at (most recent first), fallback to created_at - return [...notesList].sort((a, b) => { - const dateA = a.updated_at - ? new Date(a.updated_at).getTime() - : new Date(a.created_at).getTime(); - const dateB = b.updated_at - ? new Date(b.updated_at).getTime() - : new Date(b.created_at).getTime(); - return dateB - dateA; // Descending order (most recent first) - }); - }, [isSearchMode, searchData, notesData]); - - if (!mounted) return null; - - return createPortal( - - {open && ( - <> - {/* Backdrop */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel */} - - {/* Header */} -
-
-

{t("all_notes") || "All Notes"}

- -
- - {/* Search Input */} -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-8 h-9" - /> - {searchQuery && ( - - )} -
-
- - {/* Scrollable Content */} -
- {isLoading ? ( -
- -
- ) : error ? ( -
- {t("error_loading_notes") || "Error loading notes"} -
- ) : notes.length > 0 ? ( -
- {notes.map((note) => { - const isDeleting = deletingNoteId === note.id; - const isActive = currentNoteId === note.id; - - return ( -
- {/* Main clickable area for navigation */} - - - - - -
-

- {t("created") || "Created"}:{" "} - {format(new Date(note.created_at), "MMM d, yyyy 'at' h:mm a")} -

- {note.updated_at && ( -

- {t("updated") || "Updated"}:{" "} - {format(new Date(note.updated_at), "MMM d, yyyy 'at' h:mm a")} -

- )} -
-
-
- - {/* Actions dropdown - separate from main click area */} - setOpenDropdownId(isOpen ? note.id : null)} - > - - - - - handleDeleteNote(note.id, note.search_space_id)} - className="text-destructive focus:text-destructive" - > - - {t("delete") || "Delete"} - - - -
- ); - })} -
- ) : isSearchMode ? ( -
- -

- {t("no_results_found") || "No notes found"} -

-

- {t("try_different_search") || "Try a different search term"} -

-
- ) : ( -
- -

- {t("no_notes") || "No notes yet"} -

- {onAddNote && ( - - )} -
- )} -
- - {/* Footer with Add Note button */} - {onAddNote && notes.length > 0 && ( -
- -
- )} -
- - )} -
, - document.body - ); -} diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx deleted file mode 100644 index 8030cb9d2..000000000 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ /dev/null @@ -1,473 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { - AlertCircle, - ArrowLeftRight, - BookOpen, - Cable, - ChevronsUpDown, - Database, - ExternalLink, - FileStack, - FileText, - Info, - LogOut, - Logs, - type LucideIcon, - MessageCircle, - MessageCircleMore, - MoonIcon, - Podcast, - RefreshCw, - Settings2, - SquareLibrary, - SquareTerminal, - SunIcon, - Trash2, - Undo2, - UserPlus, - Users, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useTheme } from "next-themes"; -import { memo, useEffect, useMemo, useState } from "react"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { resetUser, trackLogout } from "@/lib/posthog/events"; - -/** - * Generates a consistent color based on a string (email) - */ -function stringToColor(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - const colors = [ - "#6366f1", // indigo - "#8b5cf6", // violet - "#a855f7", // purple - "#d946ef", // fuchsia - "#ec4899", // pink - "#f43f5e", // rose - "#ef4444", // red - "#f97316", // orange - "#eab308", // yellow - "#84cc16", // lime - "#22c55e", // green - "#14b8a6", // teal - "#06b6d4", // cyan - "#0ea5e9", // sky - "#3b82f6", // blue - ]; - return colors[Math.abs(hash) % colors.length]; -} - -/** - * Gets initials from an email address - */ -function getInitials(email: string): string { - const name = email.split("@")[0]; - const parts = name.split(/[._-]/); - if (parts.length >= 2) { - return (parts[0][0] + parts[1][0]).toUpperCase(); - } - return name.slice(0, 2).toUpperCase(); -} - -/** - * Dynamic avatar component that generates an SVG based on email - */ -function UserAvatar({ email, size = 32 }: { email: string; size?: number }) { - const bgColor = stringToColor(email); - const initials = getInitials(email); - - return ( - - Avatar for {email} - - - {initials} - - - ); -} - -import { NavChats } from "@/components/sidebar/nav-chats"; -import { NavMain } from "@/components/sidebar/nav-main"; -import { NavNotes } from "@/components/sidebar/nav-notes"; -import { NavSecondary } from "@/components/sidebar/nav-secondary"; -import { PageUsageDisplay } from "@/components/sidebar/page-usage-display"; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar"; - -// Map of icon names to their components -export const iconMap: Record = { - BookOpen, - Cable, - Database, - FileStack, - Undo2, - MessageCircleMore, - Settings2, - SquareLibrary, - FileText, - SquareTerminal, - AlertCircle, - Info, - ExternalLink, - Trash2, - Podcast, - Users, - RefreshCw, - MessageCircle, - Logs, -}; - -const defaultData = { - user: { - name: "Surf", - email: "m@example.com", - avatar: "/icon-128.png", - }, - navMain: [ - { - title: "Chat", - url: "#", - icon: "SquareTerminal", - isActive: true, - items: [], - }, - { - title: "Sources", - url: "#", - icon: "Database", - items: [ - { - title: "Manage Documents", - url: "#", - }, - { - title: "Manage Connectors", - url: "#", - }, - ], - }, - ], - navSecondary: [ - { - title: "SEARCH SPACE", - url: "#", - icon: "LifeBuoy", - }, - ], - RecentChats: [ - { - name: "Design Engineering", - url: "#", - icon: "MessageCircleMore", - id: 1001, - }, - { - name: "Sales & Marketing", - url: "#", - icon: "MessageCircleMore", - id: 1002, - }, - { - name: "Travel", - url: "#", - icon: "MessageCircleMore", - id: 1003, - }, - ], - RecentNotes: [ - { - name: "Meeting Notes", - url: "#", - icon: "FileText", - id: 2001, - }, - { - name: "Project Ideas", - url: "#", - icon: "FileText", - id: 2002, - }, - ], -}; - -interface AppSidebarProps extends React.ComponentProps { - searchSpaceId?: string; - navMain?: { - title: string; - url: string; - icon: string; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; - }[]; - navSecondary?: { - title: string; - url: string; - icon: string; - }[]; - RecentChats?: { - name: string; - url: string; - icon: string; - id?: number; - search_space_id?: number; - actions?: { - name: string; - icon: string; - onClick: () => void; - }[]; - }[]; - RecentNotes?: { - name: string; - url: string; - icon: string; - id?: number; - search_space_id?: number; - actions?: { - name: string; - icon: string; - onClick: () => void; - }[]; - }[]; - user?: { - name: string; - email: string; - avatar: string; - }; - pageUsage?: { - pagesUsed: number; - pagesLimit: number; - }; - onAddNote?: () => void; -} - -// Memoized AppSidebar component for better performance -export const AppSidebar = memo(function AppSidebar({ - searchSpaceId, - navMain = defaultData.navMain, - navSecondary = defaultData.navSecondary, - RecentChats = defaultData.RecentChats, - RecentNotes = defaultData.RecentNotes, - pageUsage, - onAddNote, - ...props -}: AppSidebarProps) { - const router = useRouter(); - const { theme, setTheme } = useTheme(); - const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom); - const [isClient, setIsClient] = useState(false); - - useEffect(() => { - setIsClient(true); - }, []); - - // Process navMain to resolve icon names to components - const processedNavMain = useMemo(() => { - return navMain.map((item) => ({ - ...item, - icon: iconMap[item.icon] || SquareTerminal, - })); - }, [navMain]); - - // Process navSecondary to resolve icon names to components - const processedNavSecondary = useMemo(() => { - return navSecondary.map((item) => ({ - ...item, - icon: iconMap[item.icon] || Undo2, - })); - }, [navSecondary]); - - // Process RecentChats to resolve icon names to components - const processedRecentChats = useMemo(() => { - return ( - RecentChats?.map((item) => ({ - ...item, - icon: iconMap[item.icon] || MessageCircleMore, - })) || [] - ); - }, [RecentChats]); - - // Process RecentNotes to resolve icon names to components - const processedRecentNotes = useMemo(() => { - return ( - RecentNotes?.map((item) => ({ - ...item, - icon: iconMap[item.icon] || FileText, - })) || [] - ); - }, [RecentNotes]); - - // Get user display name from email - const userDisplayName = user?.email ? user.email.split("@")[0] : "User"; - const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown"); - - const handleLogout = () => { - try { - // Track logout event and reset PostHog identity - trackLogout(); - resetUser(); - - if (typeof window !== "undefined") { - localStorage.removeItem("surfsense_bearer_token"); - router.push("/"); - } - } catch (error) { - console.error("Error during logout:", error); - router.push("/"); - } - }; - - return ( - - - - - - - -
- {user?.email ? ( - - ) : ( -
- )} -
-
- {userDisplayName} - {userEmail} -
- - - - - -
-
- {user?.email ? ( - - ) : ( -
- )} -
-
- {userDisplayName} - {userEmail} -
-
- - - - {searchSpaceId && ( - <> - router.push(`/dashboard/${searchSpaceId}/settings`)} - > - - Settings - - router.push(`/dashboard/${searchSpaceId}/team`)} - > - - Invite members - - - )} - router.push("/dashboard")}> - - Switch workspace - - - - - {isClient && ( - setTheme(theme === "dark" ? "light" : "dark")}> - {theme === "dark" ? ( - - ) : ( - - )} - {theme === "dark" ? "Light mode" : "Dark mode"} - - )} - - - - - Logout - - - - - - - - - - - - - - - - {pageUsage && ( - - )} - - - - ); -}); diff --git a/surfsense_web/components/sidebar/nav-chats.tsx b/surfsense_web/components/sidebar/nav-chats.tsx deleted file mode 100644 index ba0004fc8..000000000 --- a/surfsense_web/components/sidebar/nav-chats.tsx +++ /dev/null @@ -1,237 +0,0 @@ -"use client"; - -import { - ChevronRight, - FolderOpen, - Loader2, - type LucideIcon, - MessageCircleMore, - MoreHorizontal, - RefreshCw, - Trash2, -} from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; -import { cn } from "@/lib/utils"; -import { AllChatsSidebar } from "./all-chats-sidebar"; - -interface ChatAction { - name: string; - icon: string; - onClick: () => void; -} - -interface ChatItem { - name: string; - url: string; - icon: LucideIcon; - id?: number; - search_space_id?: number; - actions?: ChatAction[]; -} - -interface NavChatsProps { - chats: ChatItem[]; - defaultOpen?: boolean; - searchSpaceId?: string; -} - -// Map of icon names to their components -const actionIconMap: Record = { - MessageCircleMore, - Trash2, - MoreHorizontal, - RefreshCw, -}; - -export function NavChats({ chats, defaultOpen = true, searchSpaceId }: NavChatsProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const pathname = usePathname(); - const { setOpenMobile } = useSidebar(); - const [isDeleting, setIsDeleting] = useState(null); - const [isOpen, setIsOpen] = useState(defaultOpen); - const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false); - - // Handle chat deletion with loading state - const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => { - setIsDeleting(chatId); - try { - await deleteAction(); - } finally { - setIsDeleting(null); - } - }, []); - - // Handle chat navigation - const handleChatClick = useCallback( - (url: string) => { - router.push(url); - }, - [router] - ); - - return ( - - -
- - - - {t("recent_chats") || "Recent Chats"} - - - - {/* Action buttons - always visible on hover */} -
- {searchSpaceId && chats.length > 0 && ( - - )} -
-
- - - {chats.length > 0 ? ( - - - {chats.map((chat) => { - const isDeletingChat = isDeleting === chat.id; - const isActive = pathname === chat.url; - - return ( - - {/* Main navigation button */} - handleChatClick(chat.url)} - disabled={isDeletingChat} - className={cn( - "pr-8", // Make room for the action button - isActive && "bg-sidebar-accent text-sidebar-accent-foreground", - isDeletingChat && "opacity-50" - )} - > - - {chat.name} - - - {/* Actions dropdown - positioned absolutely */} - {chat.actions && chat.actions.length > 0 && ( -
- - - - - - {chat.actions.map((action, actionIndex) => { - const ActionIcon = actionIconMap[action.icon] || MessageCircleMore; - 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 focus:text-destructive" - : "" - } - > - - - {isDeletingChat && isDeleteAction - ? t("deleting") || "Deleting..." - : action.name} - - - ); - })} - - -
- )} -
- ); - })} -
-
- ) : ( -
- - {t("no_recent_chats") || "No recent chats"} -
- )} -
-
- - {/* All Chats Sheet */} - {searchSpaceId && ( - setOpenMobile(false)} - /> - )} -
- ); -} diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx deleted file mode 100644 index a0dbe912f..000000000 --- a/surfsense_web/components/sidebar/nav-main.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"use client"; - -import { ChevronRight, type LucideIcon } from "lucide-react"; -import { usePathname } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; - -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, -} from "@/components/ui/sidebar"; - -interface NavItem { - title: string; - url: string; - icon: LucideIcon; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; -} - -interface NavMainProps { - items: NavItem[]; -} - -export function NavMain({ items }: NavMainProps) { - const t = useTranslations("nav_menu"); - const pathname = usePathname(); - - // Translation function that handles both exact matches and fallback to original - const translateTitle = (title: string): string => { - const titleMap: Record = { - Researcher: "researcher", - "Manage LLMs": "manage_llms", - Sources: "sources", - "Manage Documents": "manage_documents", - "Manage Connectors": "manage_connectors", - Podcasts: "podcasts", - Logs: "logs", - Platform: "platform", - Team: "team", - }; - - const key = titleMap[title]; - return key ? t(key) : title; - }; - - // Check if an item is active based on pathname - const isItemActive = useCallback( - (item: NavItem): boolean => { - if (!pathname) return false; - - // For items without sub-items, check if pathname matches or starts with the URL - if (!item.items?.length) { - // Chat item: active ONLY when on new-chat page without a specific chat ID - // (i.e., exactly /dashboard/{id}/new-chat, not /dashboard/{id}/new-chat/123) - if (item.url.includes("/new-chat")) { - // Match exactly the new-chat base URL (ends with /new-chat) - return pathname.endsWith("/new-chat"); - } - // Logs item: active when on logs page - if (item.url.includes("/logs")) { - return pathname.includes("/logs"); - } - // Check exact match or prefix match - return pathname === item.url || pathname.startsWith(`${item.url}/`); - } - - // For items with sub-items (like Sources), check if any sub-item URL matches - return item.items.some( - (subItem) => pathname === subItem.url || pathname.startsWith(subItem.url) - ); - }, - [pathname] - ); - - // Memoize items to prevent unnecessary re-renders - const memoizedItems = useMemo(() => items, [items]); - - // Track expanded state for items with sub-menus (like Sources) - const [expandedItems, setExpandedItems] = useState>(() => { - const initial: Record = {}; - items.forEach((item) => { - if (item.items?.length) { - initial[item.title] = item.isActive ?? false; - } - }); - return initial; - }); - - // Handle collapsible state change - const handleOpenChange = useCallback((title: string, isOpen: boolean) => { - setExpandedItems((prev) => ({ ...prev, [title]: isOpen })); - }, []); - - return ( - - {translateTitle("Platform")} - - {memoizedItems.map((item, index) => { - const translatedTitle = translateTitle(item.title); - const hasSub = !!item.items?.length; - const isActive = isItemActive(item); - const isItemOpen = expandedItems[item.title] ?? isActive ?? false; - return ( - handleOpenChange(item.title, open) : undefined} - defaultOpen={!hasSub ? isActive : undefined} - > - - {hasSub ? ( - // When the item has children, make the whole row a collapsible trigger - <> - - - - - - - - - - Toggle submenu - - - - - - {item.items?.map((subItem, subIndex) => { - const translatedSubTitle = translateTitle(subItem.title); - const isDocumentsLink = - subItem.title === "Manage Documents" || - translatedSubTitle.toLowerCase().includes("documents"); - return ( - - - - {translatedSubTitle} - - - - ); - })} - - - - ) : ( - // Leaf item: treat as a normal link - - - - {translatedTitle} - - - )} - - - ); - })} - - - ); -} diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx deleted file mode 100644 index e9f94fe80..000000000 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client"; - -import { - ChevronRight, - FileText, - FolderOpen, - Loader2, - type LucideIcon, - MoreHorizontal, - Plus, - Trash2, -} from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; -import { useLogsSummary } from "@/hooks/use-logs"; -import { cn } from "@/lib/utils"; -import { AllNotesSidebar } from "./all-notes-sidebar"; - -interface NoteAction { - name: string; - icon: string; - onClick: () => void; -} - -interface NoteItem { - name: string; - url: string; - icon: LucideIcon; - id?: number; - search_space_id?: number; - actions?: NoteAction[]; -} - -interface NavNotesProps { - notes: NoteItem[]; - onAddNote?: () => void; - defaultOpen?: boolean; - searchSpaceId?: string; -} - -// Map of icon names to their components -const actionIconMap: Record = { - FileText, - Trash2, - MoreHorizontal, -}; - -export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const pathname = usePathname(); - const { setOpenMobile } = useSidebar(); - const [isDeleting, setIsDeleting] = useState(null); - const [isOpen, setIsOpen] = useState(defaultOpen); - const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); - - // Poll for active reindexing tasks to show inline loading indicators - // Smart polling: only polls when there are active tasks, stops when idle - const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, { - enablePolling: true, - refetchInterval: 5000, // Poll every 5 seconds when tasks are active - }); - - // Create a Set of document IDs that are currently being reindexed - const reindexingDocumentIds = useMemo(() => { - if (!summary?.active_tasks) return new Set(); - return new Set( - summary.active_tasks - .filter((task) => task.document_id != null) - .map((task) => task.document_id as number) - ); - }, [summary?.active_tasks]); - - // Handle note deletion with loading state - const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { - setIsDeleting(noteId); - try { - await deleteAction(); - } finally { - setIsDeleting(null); - } - }, []); - - // Handle note navigation - const handleNoteClick = useCallback( - (url: string) => { - router.push(url); - }, - [router] - ); - - return ( - - -
- - - - {t("notes") || "Notes"} - - - - {/* Action buttons - always visible on hover */} -
- {searchSpaceId && notes.length > 0 && ( - - )} - {onAddNote && ( - - )} -
-
- - - - - {notes.length > 0 ? ( - notes.map((note) => { - const isDeletingNote = isDeleting === note.id; - const isActive = pathname === note.url; - const isReindexing = note.id ? reindexingDocumentIds.has(note.id) : false; - - return ( - - {/* Main navigation button */} - handleNoteClick(note.url)} - disabled={isDeletingNote} - className={cn( - "pr-8", // Make room for the action button - isActive && "bg-sidebar-accent text-sidebar-accent-foreground", - isDeletingNote && "opacity-50" - )} - > - {isReindexing ? ( - - ) : ( - - )} - {note.name} - - - {/* Actions dropdown - positioned absolutely */} - {note.actions && note.actions.length > 0 && ( -
- - - - - - {note.actions.map((action, actionIndex) => { - const ActionIcon = actionIconMap[action.icon] || FileText; - const isDeleteAction = action.name.toLowerCase().includes("delete"); - - return ( - { - if (isDeleteAction) { - handleDeleteNote(note.id || 0, action.onClick); - } else { - action.onClick(); - } - }} - disabled={isDeletingNote} - className={ - isDeleteAction - ? "text-destructive focus:text-destructive" - : "" - } - > - - - {isDeletingNote && isDeleteAction - ? t("deleting") || "Deleting..." - : action.name} - - - ); - })} - - -
- )} -
- ); - }) - ) : ( - - {onAddNote ? ( - - - {t("create_new_note") || "Create a new note"} - - ) : ( - - - {t("no_notes") || "No notes yet"} - - )} - - )} -
-
-
-
- - {/* All Notes Sheet */} - {searchSpaceId && ( - setOpenMobile(false)} - /> - )} -
- ); -} diff --git a/surfsense_web/components/sidebar/nav-secondary.tsx b/surfsense_web/components/sidebar/nav-secondary.tsx deleted file mode 100644 index 23aeabc38..000000000 --- a/surfsense_web/components/sidebar/nav-secondary.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import type { LucideIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import type * as React from "react"; -import { useMemo } from "react"; - -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar"; - -interface NavSecondaryItem { - title: string; - url: string; - icon: LucideIcon; -} - -export function NavSecondary({ - items, - ...props -}: { - items: NavSecondaryItem[]; -} & React.ComponentPropsWithoutRef) { - const t = useTranslations("sidebar"); - - // Memoize items to prevent unnecessary re-renders - const memoizedItems = useMemo(() => items, [items]); - - return ( - - {t("search_space")} - - {memoizedItems.map((item, index) => ( - - {item.url === "#" ? ( - // Non-interactive display item (e.g., search space name) -
- - {item.title} -
- ) : ( - // Interactive link item - - - - {item.title} - - - )} -
- ))} -
-
- ); -} diff --git a/surfsense_web/components/sidebar/page-usage-display.tsx b/surfsense_web/components/sidebar/page-usage-display.tsx deleted file mode 100644 index 6c640c0aa..000000000 --- a/surfsense_web/components/sidebar/page-usage-display.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { Mail } from "lucide-react"; -import { Progress } from "@/components/ui/progress"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - useSidebar, -} from "@/components/ui/sidebar"; - -interface PageUsageDisplayProps { - pagesUsed: number; - pagesLimit: number; -} - -export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { - const { state } = useSidebar(); - const usagePercentage = (pagesUsed / pagesLimit) * 100; - const isCollapsed = state === "collapsed"; - - return ( - - - Page Usage - - -
- {isCollapsed ? ( - // Show only a compact progress indicator when collapsed -
- -
- ) : ( - // Show full details when expanded - <> -
- - {pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages - - {usagePercentage.toFixed(0)}% -
- - - - Contact to increase limits - - - )} -
-
-
- ); -} From d689a8728aa351cefe7686f3fd0ed82f57abef04 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 8 Jan 2026 19:11:26 +0200 Subject: [PATCH 5/5] fix: query key caching for threads --- surfsense_web/components/onboarding-tour.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 958bb43b0..717a27607 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -407,7 +407,7 @@ export function OnboardingTour() { // Fetch threads data const { data: threadsData } = useQuery({ - queryKey: ["threads", searchSpaceId], + queryKey: ["threads", searchSpaceId, { limit: 1 }], queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist enabled: !!searchSpaceId, });