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";