SurfSense/surfsense_web/components/sidebar/AppSidebarProvider.tsx

386 lines
11 KiB
TypeScript
Raw Normal View History

2025-07-27 10:05:37 -07:00
"use client";
2025-04-07 23:47:06 -07:00
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
2025-08-08 11:17:43 -07:00
import { Trash2 } from "lucide-react";
import { 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";
2025-07-27 10:05:37 -07:00
import { AppSidebar } from "@/components/sidebar/app-sidebar";
2025-07-27 10:41:15 -07:00
import { Button } from "@/components/ui/button";
2025-04-07 23:47:06 -07:00
import {
2025-07-27 10:05:37 -07:00
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
2025-04-07 23:47:06 -07:00
} 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";
2025-04-07 23:47:06 -07:00
interface AppSidebarProviderProps {
2025-07-27 10:05:37 -07:00
searchSpaceId: string;
navSecondary: {
title: string;
url: string;
icon: string;
}[];
navMain: {
title: string;
url: string;
icon: string;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
2025-04-07 23:47:06 -07:00
}
export function AppSidebarProvider({
2025-07-27 10:05:37 -07:00
searchSpaceId,
navSecondary,
navMain,
2025-04-07 23:47:06 -07:00
}: AppSidebarProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
const router = useRouter();
const queryClient = useQueryClient();
const [isDeletingThread, setIsDeletingThread] = useState(false);
2025-12-19 15:38:39 -08:00
// Editor state for handling unsaved changes
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
// Fetch new chat threads
const {
data: threadsData,
error: threadError,
isLoading: isLoadingThreads,
refetch: refetchThreads,
} = useQuery({
queryKey: ["threads", searchSpaceId],
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
enabled: !!searchSpaceId,
});
const {
data: searchSpace,
isLoading: isLoadingSearchSpace,
error: searchSpaceError,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
enabled: !!searchSpaceId,
});
const { data: user } = useAtomValue(currentUserAtom);
// Fetch notes
const {
data: notesData,
error: notesError,
isLoading: isLoadingNotes,
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,
});
2025-07-27 10:05:37 -07:00
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [threadToDelete, setThreadToDelete] = useState<{ id: number; name: string } | null>(null);
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
2025-12-19 15:38:39 -08:00
const [noteToDelete, setNoteToDelete] = useState<{
id: number;
name: string;
search_space_id: number;
} | null>(null);
const [isDeletingNote, setIsDeletingNote] = useState(false);
2025-04-07 23:47:06 -07:00
2025-08-02 21:20:36 -07:00
// Retry function
const retryFetch = useCallback(() => {
fetchSearchSpace();
2025-11-18 22:12:47 +02:00
}, [fetchSearchSpace]);
2025-08-02 21:20:36 -07:00
// 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]);
2025-08-02 21:20:36 -07:00
// Handle delete thread
const handleDeleteThread = useCallback(async () => {
if (!threadToDelete) return;
setIsDeletingThread(true);
2025-07-27 10:05:37 -07:00
try {
await deleteThread(threadToDelete.id);
// Invalidate threads query to refresh the list
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
2025-07-27 10:05:37 -07:00
} catch (error) {
console.error("Error deleting thread:", error);
2025-07-27 10:05:37 -07:00
} finally {
setIsDeletingThread(false);
2025-07-27 10:05:37 -07:00
setShowDeleteDialog(false);
setThreadToDelete(null);
2025-07-27 10:05:37 -07:00
}
}, [threadToDelete, queryClient, searchSpaceId]);
// 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]);
2025-08-02 21:20:36 -07:00
// Memoized fallback chats
const fallbackChats = useMemo(() => {
if (threadError) {
2025-08-02 21:20:36 -07:00
return [
{
name: t("error_loading_chats"),
2025-08-02 21:20:36 -07:00
url: "#",
icon: "AlertCircle",
id: 0,
search_space_id: Number(searchSpaceId),
actions: [
{
name: tCommon("retry"),
2025-08-02 21:20:36 -07:00
icon: "RefreshCw",
onClick: () => refetchThreads(),
2025-08-02 21:20:36 -07:00
},
],
},
];
}
2025-04-07 23:47:06 -07:00
2025-08-02 21:20:36 -07:00
return [];
}, [threadError, searchSpaceId, refetchThreads, t, tCommon]);
2025-04-07 23:47:06 -07:00
2025-07-27 10:05:37 -07:00
// Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
2025-04-07 23:47:06 -07:00
// 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: () => {
2025-12-19 15:38:39 -08:00
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`;
2025-12-19 15:38:39 -08:00
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]);
2025-08-02 21:20:36 -07:00
// Memoized updated navSecondary
const updatedNavSecondary = useMemo(() => {
const updated = [...navSecondary];
if (updated.length > 0) {
2025-08-02 21:20:36 -07:00
updated[0] = {
...updated[0],
title:
searchSpace?.name ||
(isLoadingSearchSpace
? tCommon("loading")
2025-08-02 21:20:36 -07:00
: searchSpaceError
? t("error_loading_space")
: t("unknown_search_space")),
2025-08-02 21:20:36 -07:00
};
}
return updated;
}, [navSecondary, searchSpace?.name, isLoadingSearchSpace, searchSpaceError, t, tCommon]);
2025-08-02 21:20:36 -07:00
// Prepare page usage data
const pageUsage = user
? {
pagesUsed: user.pages_used,
pagesLimit: user.pages_limit,
}
: undefined;
2025-07-27 10:05:37 -07:00
return (
<>
<AppSidebar
searchSpaceId={searchSpaceId}
navSecondary={updatedNavSecondary}
navMain={navMain}
RecentChats={displayChats}
RecentNotes={recentNotes}
onAddNote={handleAddNote}
pageUsage={pageUsage}
/>
2025-08-02 21:20:36 -07:00
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>{t("delete_chat")}</span>
2025-08-02 21:20:36 -07:00
</DialogTitle>
<DialogDescription>
{t("delete_chat_confirm")} <span className="font-medium">{threadToDelete?.name}</span>
? {t("action_cannot_undone")}
2025-08-02 21:20:36 -07:00
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteDialog(false)}
disabled={isDeletingThread}
2025-08-02 21:20:36 -07:00
>
{tCommon("cancel")}
2025-08-02 21:20:36 -07:00
</Button>
<Button
variant="destructive"
onClick={handleDeleteThread}
disabled={isDeletingThread}
2025-08-02 21:20:36 -07:00
className="gap-2"
>
{isDeletingThread ? (
2025-08-02 21:20:36 -07:00
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")}
2025-08-02 21:20:36 -07:00
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
2025-08-02 21:20:36 -07:00
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Note Confirmation Dialog */}
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>{t("delete_note")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
{t("action_cannot_undone")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteNoteDialog(false)}
disabled={isDeletingNote}
>
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDeleteNote}
disabled={isDeletingNote}
className="gap-2"
>
{isDeletingNote ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-07-27 10:05:37 -07:00
</>
);
}