mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 16:56:22 +02:00
feat: add All Notes sidebar component and integrate with NavNotes
- Introduced a new AllNotesSidebar component for displaying all notes in a sidebar. - Enhanced NavNotes to include a button for opening the All Notes sidebar. - Updated translations to support new sidebar labels and error messages. - Improved note deletion handling and loading states within the sidebar. - Refactored AppSidebar to pass searchSpaceId to NavNotes for better context.
This commit is contained in:
parent
e9d3d36dfc
commit
5da41d91c8
5 changed files with 455 additions and 18 deletions
404
surfsense_web/components/sidebar/all-notes-sidebar.tsx
Normal file
404
surfsense_web/components/sidebar/all-notes-sidebar.tsx
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
FileText,
|
||||
type LucideIcon,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Map of icon names to their components
|
||||
const actionIconMap: Record<string, LucideIcon> = {
|
||||
FileText,
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
};
|
||||
|
||||
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 AllNotesSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
onAddNote?: () => void;
|
||||
hoverTimeoutRef?: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
}
|
||||
|
||||
export function AllNotesSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onAddNote,
|
||||
hoverTimeoutRef,
|
||||
}: AllNotesSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
const [sidebarLeft, setSidebarLeft] = useState(0); // Position from left edge of viewport
|
||||
|
||||
// Calculate the sidebar's right edge position
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const updatePosition = () => {
|
||||
// Find the actual sidebar element (the fixed positioned one)
|
||||
const sidebarElement = document.querySelector(
|
||||
'[data-slot="sidebar"][data-sidebar="sidebar"]'
|
||||
) as HTMLElement;
|
||||
|
||||
if (sidebarElement) {
|
||||
const rect = sidebarElement.getBoundingClientRect();
|
||||
// Set the left position to be the right edge of the sidebar
|
||||
setSidebarLeft(rect.right);
|
||||
} else {
|
||||
// Fallback: try to find any sidebar element
|
||||
const fallbackSidebar = document.querySelector('[data-slot="sidebar"]') as HTMLElement;
|
||||
if (fallbackSidebar) {
|
||||
const rect = fallbackSidebar.getBoundingClientRect();
|
||||
setSidebarLeft(rect.right);
|
||||
} else {
|
||||
// Final fallback: use CSS variable
|
||||
const sidebarWidth = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--sidebar-width")
|
||||
.trim();
|
||||
if (sidebarWidth) {
|
||||
const remValue = parseFloat(sidebarWidth);
|
||||
setSidebarLeft(remValue * 16); // Convert rem to px
|
||||
} else {
|
||||
setSidebarLeft(256); // Default 16rem
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
// Update on window resize and scroll
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
// Use MutationObserver to watch for sidebar state changes
|
||||
const observer = new MutationObserver(updatePosition);
|
||||
const sidebarWrapper = document.querySelector('[data-slot="sidebar-wrapper"]');
|
||||
if (sidebarWrapper) {
|
||||
observer.observe(sidebarWrapper, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-state", "class"],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Also observe the sidebar element directly if it exists
|
||||
const sidebarElement = document.querySelector('[data-slot="sidebar"]');
|
||||
if (sidebarElement) {
|
||||
observer.observe(sidebarElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-state", "class"],
|
||||
childList: false,
|
||||
subtree: false,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle Escape key to close sidebar
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Fetch all notes
|
||||
const {
|
||||
data: notesData,
|
||||
error: notesError,
|
||||
isLoading: isLoadingNotes,
|
||||
refetch: refetchNotes,
|
||||
} = useQuery({
|
||||
queryKey: ["all-notes", searchSpaceId],
|
||||
queryFn: () =>
|
||||
notesApiService.getNotes({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
page_size: 1000, // Get all notes
|
||||
}),
|
||||
enabled: !!searchSpaceId && open, // Only fetch when sidebar is open
|
||||
});
|
||||
|
||||
// Handle note deletion with loading state
|
||||
const handleDeleteNote = useCallback(
|
||||
async (noteId: number, deleteAction: () => void) => {
|
||||
setIsDeleting(noteId);
|
||||
try {
|
||||
await deleteAction();
|
||||
refetchNotes();
|
||||
} finally {
|
||||
setIsDeleting(null);
|
||||
}
|
||||
},
|
||||
[refetchNotes]
|
||||
);
|
||||
|
||||
// Transform notes to the format expected by the component
|
||||
const allNotes = useMemo(() => {
|
||||
return notesData?.items
|
||||
? notesData.items.map((note) => ({
|
||||
name: note.title,
|
||||
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
|
||||
icon: FileText as LucideIcon,
|
||||
id: note.id,
|
||||
search_space_id: note.search_space_id,
|
||||
actions: [
|
||||
{
|
||||
name: "Delete",
|
||||
icon: "Trash2",
|
||||
onClick: async () => {
|
||||
try {
|
||||
await notesApiService.deleteNote({
|
||||
search_space_id: note.search_space_id,
|
||||
note_id: note.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting note:", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
: [];
|
||||
}, [notesData]);
|
||||
|
||||
// Enhanced note item component
|
||||
const NoteItemComponent = useCallback(
|
||||
({ note }: { note: NoteItem }) => {
|
||||
const isDeletingNote = isDeleting === note.id;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={note.id ? `note-${note.id}` : `note-${note.name}`}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => {
|
||||
router.push(note.url);
|
||||
onOpenChange(false); // Close sidebar when navigating
|
||||
}}
|
||||
disabled={isDeletingNote}
|
||||
className={cn("group/item relative", isDeletingNote && "opacity-50")}
|
||||
size="sm"
|
||||
>
|
||||
<note.icon className="h-4 w-4 shrink-0" />
|
||||
<span className={cn("truncate", isDeletingNote && "opacity-50")}>{note.name}</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
{note.actions && note.actions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="opacity-0 group-hover/item:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48" side="left" align="start">
|
||||
{note.actions.map((action, actionIndex) => {
|
||||
const ActionIcon = actionIconMap[action.icon] || FileText;
|
||||
const isDeleteAction = action.name.toLowerCase().includes("delete");
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${action.name}-${actionIndex}`}
|
||||
onClick={() => {
|
||||
if (isDeleteAction) {
|
||||
handleDeleteNote(note.id || 0, action.onClick);
|
||||
} else {
|
||||
action.onClick();
|
||||
}
|
||||
}}
|
||||
disabled={isDeletingNote}
|
||||
className={isDeleteAction ? "text-destructive" : ""}
|
||||
>
|
||||
<ActionIcon className="mr-2 h-4 w-4" />
|
||||
<span>
|
||||
{isDeletingNote && isDeleteAction ? "Deleting..." : action.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
},
|
||||
[isDeleting, router, onOpenChange, handleDeleteNote]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Sidebar */}
|
||||
<section
|
||||
ref={sidebarRef}
|
||||
aria-label="All notes sidebar"
|
||||
className={cn(
|
||||
"fixed top-16 bottom-0 z-[60] w-80 bg-sidebar text-sidebar-foreground shadow-2xl",
|
||||
"transition-all duration-300 ease-out",
|
||||
!open && "pointer-events-none"
|
||||
)}
|
||||
style={{
|
||||
// Position it to slide from the right edge of the main sidebar
|
||||
left: `${sidebarLeft}px`,
|
||||
transform: open ? `scaleX(1)` : `scaleX(0)`,
|
||||
transformOrigin: "left",
|
||||
opacity: open ? 1 : 0,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
// Clear any pending close timeout when hovering over sidebar
|
||||
if (hoverTimeoutRef?.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Close sidebar when mouse leaves
|
||||
if (hoverTimeoutRef) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
}, 200);
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex h-16 shrink-0 items-center justify-between px-4">
|
||||
<h2 className="text-sm font-semibold">{t("all_notes") || "All Notes"}</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
{isLoadingNotes ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled size="sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("loading") || "Loading..."}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : notesError ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled size="sm">
|
||||
<span className="text-xs text-destructive">
|
||||
{t("error_loading_notes") || "Error loading notes"}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : allNotes.length > 0 ? (
|
||||
<SidebarMenu>
|
||||
{allNotes.map((note) => (
|
||||
<NoteItemComponent key={note.id || note.name} note={note} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
) : (
|
||||
<SidebarMenuItem>
|
||||
{onAddNote ? (
|
||||
<SidebarMenuButton
|
||||
onClick={() => {
|
||||
onAddNote();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-sidebar-foreground text-xs"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>{t("create_new_note") || "Create a new note"}</span>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuButton disabled className="text-muted-foreground text-xs" size="sm">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{t("no_notes") || "No notes yet"}</span>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer with Add Note button */}
|
||||
{onAddNote && (
|
||||
<div className="p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onAddNote();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("create_new_note") || "Create a new note"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -453,7 +453,7 @@ export const AppSidebar = memo(function AppSidebar({
|
|||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<NavNotes notes={processedRecentNotes} onAddNote={onAddNote} />
|
||||
<NavNotes notes={processedRecentNotes} onAddNote={onAddNote} searchSpaceId={searchSpaceId} />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import {
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileText,
|
||||
type LucideIcon,
|
||||
MoreHorizontal,
|
||||
|
|
@ -10,11 +11,10 @@ import {
|
|||
RefreshCw,
|
||||
Share,
|
||||
Trash2,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { AllNotesSidebar } from "./all-notes-sidebar";
|
||||
|
||||
// Map of icon names to their components
|
||||
const actionIconMap: Record<string, LucideIcon> = {
|
||||
|
|
@ -66,14 +67,17 @@ interface NavNotesProps {
|
|||
notes: NoteItem[];
|
||||
onAddNote?: () => void;
|
||||
defaultOpen?: boolean;
|
||||
searchSpaceId?: string;
|
||||
}
|
||||
|
||||
export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps) {
|
||||
export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Handle note deletion with loading state
|
||||
const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
|
||||
|
|
@ -166,18 +170,31 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps
|
|||
</SidebarGroupLabel>
|
||||
</CollapsibleTrigger>
|
||||
<div className="absolute top-1.5 right-1 flex items-center gap-0.5 opacity-0 group-hover/header:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Add your view all handler here
|
||||
// router.push('/dashboard/[search_space_id]/notes');
|
||||
}}
|
||||
aria-label="View all notes"
|
||||
className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
{searchSpaceId && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={(e) => {
|
||||
e.stopPropagation();
|
||||
// Clear any pending close timeout
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
setIsAllNotesSidebarOpen(true);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.stopPropagation();
|
||||
// Add a small delay before closing to allow moving to the sidebar
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsAllNotesSidebarOpen(false);
|
||||
}, 200);
|
||||
}}
|
||||
aria-label="View all notes"
|
||||
className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onAddNote && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -223,6 +240,15 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps
|
|||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{searchSpaceId && (
|
||||
<AllNotesSidebar
|
||||
open={isAllNotesSidebarOpen}
|
||||
onOpenChange={setIsAllNotesSidebarOpen}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onAddNote={onAddNote}
|
||||
hoverTimeoutRef={hoverTimeoutRef}
|
||||
/>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue