mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
chore: remove old sidebar components
This commit is contained in:
parent
ab052bf1f2
commit
16c94d1c47
9 changed files with 0 additions and 2553 deletions
|
|
@ -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 (
|
||||
<>
|
||||
<AppSidebar
|
||||
searchSpaceId={searchSpaceId}
|
||||
navSecondary={updatedNavSecondary}
|
||||
navMain={navMain}
|
||||
RecentChats={displayChats}
|
||||
RecentNotes={recentNotes}
|
||||
onAddNote={handleAddNote}
|
||||
pageUsage={pageUsage}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("delete_chat_confirm")} <span className="font-medium">{threadToDelete?.name}</span>
|
||||
? {t("action_cannot_undone")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
disabled={isDeletingThread}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteThread}
|
||||
disabled={isDeletingThread}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeletingThread ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<number | null>(null);
|
||||
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(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(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[70] bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-y-0 left-0 z-[70] w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("all_chats") || "All Chats"}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("all_chats") || "All Chats"}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_chats") || "Search chats..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-8 h-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab toggle for active/archived (only show when not searching) */}
|
||||
{!isSearchMode && (
|
||||
<div className="flex-shrink-0 flex border-b mx-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(false)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
!showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Active ({activeCount})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(true)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
||||
showArchived
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Archived ({archivedCount})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-sm text-destructive">
|
||||
{t("error_loading_chats") || "Error loading chats"}
|
||||
</div>
|
||||
) : threads.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{threads.map((thread) => {
|
||||
const isDeleting = deletingThreadId === thread.id;
|
||||
const isArchiving = archivingThreadId === thread.id;
|
||||
const isBusy = isDeleting || isArchiving;
|
||||
const isActive = currentChatId === thread.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={thread.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{/* Main clickable area for navigation */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleThreadClick(thread.id)}
|
||||
disabled={isBusy}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<p>
|
||||
{t("updated") || "Updated"}:{" "}
|
||||
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu
|
||||
open={openDropdownId === thread.id}
|
||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 shrink-0",
|
||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||
"transition-opacity"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-[80]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{thread.archived ? (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("unarchive") || "Restore"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArchiveIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("archive") || "Archive"}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteThread(thread.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : isSearchMode ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("no_chats_found") || "No chats found"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("try_different_search") || "Try a different search term"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<MessageCircleMore className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{showArchived
|
||||
? t("no_archived_chats") || "No archived chats"
|
||||
: t("no_chats") || "No chats yet"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("start_new_chat_hint") || "Start a new chat from the chat page"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -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<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(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(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[70] bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-y-0 left-0 z-[70] w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("all_notes") || "All Notes"}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 p-4 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("all_notes") || "All Notes"}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_notes") || "Search notes..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-8 h-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-sm text-destructive">
|
||||
{t("error_loading_notes") || "Error loading notes"}
|
||||
</div>
|
||||
) : notes.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{notes.map((note) => {
|
||||
const isDeleting = deletingNoteId === note.id;
|
||||
const isActive = currentNoteId === note.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={note.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"transition-colors cursor-pointer",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isDeleting && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{/* Main clickable area for navigation */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNoteClick(note.id, note.search_space_id)}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{note.title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
{t("created") || "Created"}:{" "}
|
||||
{format(new Date(note.created_at), "MMM d, yyyy 'at' h:mm a")}
|
||||
</p>
|
||||
{note.updated_at && (
|
||||
<p>
|
||||
{t("updated") || "Updated"}:{" "}
|
||||
{format(new Date(note.updated_at), "MMM d, yyyy 'at' h:mm a")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions dropdown - separate from main click area */}
|
||||
<DropdownMenu
|
||||
open={openDropdownId === note.id}
|
||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? note.id : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 shrink-0",
|
||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||
"transition-opacity"
|
||||
)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-[80]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteNote(note.id, note.search_space_id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : isSearchMode ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("no_results_found") || "No notes found"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{t("try_different_search") || "Try a different search term"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("no_notes") || "No notes yet"}
|
||||
</p>
|
||||
{onAddNote && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onAddNote();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("create_new_note") || "Create a note"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with Add Note button */}
|
||||
{onAddNote && notes.length > 0 && (
|
||||
<div className="flex-shrink-0 p-3">
|
||||
<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>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
className="rounded-lg"
|
||||
role="img"
|
||||
aria-labelledby="sidebar-avatar-title"
|
||||
>
|
||||
<title id="sidebar-avatar-title">Avatar for {email}</title>
|
||||
<rect width="32" height="32" rx="6" fill={bgColor} />
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
dominantBaseline="central"
|
||||
textAnchor="middle"
|
||||
fill="white"
|
||||
fontSize="12"
|
||||
fontWeight="600"
|
||||
fontFamily="system-ui, sans-serif"
|
||||
>
|
||||
{initials}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, LucideIcon> = {
|
||||
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<typeof Sidebar> {
|
||||
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 (
|
||||
<Sidebar variant="inset" collapsible="icon" aria-label="Main navigation" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div className="flex aspect-square size-8 items-center justify-center">
|
||||
{user?.email ? (
|
||||
<UserAvatar email={user.email} size={32} />
|
||||
) : (
|
||||
<div className="size-8 rounded-lg bg-sidebar-primary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{userDisplayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{userEmail}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<div className="flex aspect-square size-8 items-center justify-center">
|
||||
{user?.email ? (
|
||||
<UserAvatar email={user.email} size={32} />
|
||||
) : (
|
||||
<div className="size-8 rounded-lg bg-sidebar-primary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{userDisplayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{userEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{searchSpaceId && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/team`)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite members
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => router.push("/dashboard")}>
|
||||
<ArrowLeftRight className="mr-2 h-4 w-4" />
|
||||
Switch workspace
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{isClient && (
|
||||
<DropdownMenuItem onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
|
||||
{theme === "dark" ? (
|
||||
<SunIcon className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<MoonIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="gap-1">
|
||||
<NavMain items={processedNavMain} />
|
||||
|
||||
<NavChats chats={processedRecentChats} searchSpaceId={searchSpaceId} />
|
||||
|
||||
<NavNotes
|
||||
notes={processedRecentNotes}
|
||||
onAddNote={onAddNote}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{pageUsage && (
|
||||
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
|
||||
)}
|
||||
<NavSecondary items={processedNavSecondary} className="mt-auto" />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<string, LucideIcon> = {
|
||||
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<number | null>(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 (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="flex items-center group/header">
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5 flex-1">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span>{t("recent_chats") || "Recent Chats"}</span>
|
||||
</SidebarGroupLabel>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Action buttons - always visible on hover */}
|
||||
<div className="flex items-center gap-0.5 md:opacity-0 md:group-hover/header:opacity-100 transition-opacity pr-1">
|
||||
{searchSpaceId && chats.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsAllChatsSidebarOpen(true);
|
||||
}}
|
||||
aria-label={t("view_all_chats") || "View all chats"}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
{chats.length > 0 ? (
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{chats.map((chat) => {
|
||||
const isDeletingChat = isDeleting === chat.id;
|
||||
const isActive = pathname === chat.url;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={chat.id || chat.name} className="group/chat">
|
||||
{/* Main navigation button */}
|
||||
<SidebarMenuButton
|
||||
onClick={() => 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.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{chat.name}</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
{/* Actions dropdown - positioned absolutely */}
|
||||
{chat.actions && chat.actions.length > 0 && (
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
"md:opacity-0 md:group-hover/chat:opacity-100 md:focus:opacity-100",
|
||||
"data-[state=open]:opacity-100",
|
||||
"transition-opacity"
|
||||
)}
|
||||
disabled={isDeletingChat}
|
||||
>
|
||||
{isDeletingChat ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{t("more_options") || "More options"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right" className="w-40">
|
||||
{chat.actions.map((action, actionIndex) => {
|
||||
const ActionIcon = actionIconMap[action.icon] || MessageCircleMore;
|
||||
const isDeleteAction = action.name.toLowerCase().includes("delete");
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${action.name}-${actionIndex}`}
|
||||
onClick={() => {
|
||||
if (isDeleteAction) {
|
||||
handleDeleteChat(chat.id || 0, action.onClick);
|
||||
} else {
|
||||
action.onClick();
|
||||
}
|
||||
}}
|
||||
disabled={isDeletingChat}
|
||||
className={
|
||||
isDeleteAction
|
||||
? "text-destructive focus:text-destructive"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<ActionIcon className="mr-2 h-4 w-4" />
|
||||
<span>
|
||||
{isDeletingChat && isDeleteAction
|
||||
? t("deleting") || "Deleting..."
|
||||
: action.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-muted-foreground/60 text-xs">
|
||||
<MessageCircleMore className="h-3.5 w-3.5" />
|
||||
<span>{t("no_recent_chats") || "No recent chats"}</span>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* All Chats Sheet */}
|
||||
{searchSpaceId && (
|
||||
<AllChatsSidebar
|
||||
open={isAllChatsSidebarOpen}
|
||||
onOpenChange={setIsAllChatsSidebarOpen}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onCloseMobileSidebar={() => setOpenMobile(false)}
|
||||
/>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
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 (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{translateTitle("Platform")}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{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 (
|
||||
<Collapsible
|
||||
key={`${item.title}-${index}`}
|
||||
asChild
|
||||
open={hasSub ? isItemOpen : undefined}
|
||||
onOpenChange={hasSub ? (open) => handleOpenChange(item.title, open) : undefined}
|
||||
defaultOpen={!hasSub ? isActive : undefined}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
{hasSub ? (
|
||||
// When the item has children, make the whole row a collapsible trigger
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={translatedTitle}
|
||||
isActive={isActive}
|
||||
aria-label={`${translatedTitle} with submenu`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
{...(item.title === "Sources" ? { "data-joyride": "sources-menu" } : {})}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{translatedTitle}</span>
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
className="data-[state=open]:rotate-90 transition-transform duration-200"
|
||||
aria-label={`Toggle ${translatedTitle} submenu`}
|
||||
>
|
||||
<ChevronRight />
|
||||
<span className="sr-only">Toggle submenu</span>
|
||||
</SidebarMenuAction>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 duration-200">
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem, subIndex) => {
|
||||
const translatedSubTitle = translateTitle(subItem.title);
|
||||
const isDocumentsLink =
|
||||
subItem.title === "Manage Documents" ||
|
||||
translatedSubTitle.toLowerCase().includes("documents");
|
||||
return (
|
||||
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
|
||||
<SidebarMenuSubButton asChild aria-label={translatedSubTitle}>
|
||||
<a
|
||||
href={subItem.url}
|
||||
{...(isDocumentsLink
|
||||
? { "data-joyride": "documents-sidebar" }
|
||||
: {})}
|
||||
>
|
||||
<span>{translatedSubTitle}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
) : (
|
||||
// Leaf item: treat as a normal link
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={translatedTitle}
|
||||
isActive={isActive}
|
||||
aria-label={translatedTitle}
|
||||
>
|
||||
<a
|
||||
href={item.url}
|
||||
{...(item.title === "Documents" ||
|
||||
translatedTitle.toLowerCase() === "documents"
|
||||
? { "data-joyride": "documents-sidebar" }
|
||||
: {})}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{translatedTitle}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, LucideIcon> = {
|
||||
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<number | null>(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<number>();
|
||||
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 (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="flex items-center group/header">
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5 flex-1">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span>{t("notes") || "Notes"}</span>
|
||||
</SidebarGroupLabel>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Action buttons - always visible on hover */}
|
||||
<div className="flex items-center gap-0.5 md:opacity-0 md:group-hover/header:opacity-100 transition-opacity pr-1">
|
||||
{searchSpaceId && notes.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsAllNotesSidebarOpen(true);
|
||||
}}
|
||||
aria-label={t("view_all_notes") || "View all notes"}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onAddNote && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddNote();
|
||||
}}
|
||||
aria-label={t("add_note") || "Add note"}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{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 (
|
||||
<SidebarMenuItem key={note.id || note.name} className="group/note">
|
||||
{/* Main navigation button */}
|
||||
<SidebarMenuButton
|
||||
onClick={() => 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 ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
<note.icon className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{note.name}</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
{/* Actions dropdown - positioned absolutely */}
|
||||
{note.actions && note.actions.length > 0 && (
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
"md:opacity-0 md:group-hover/note:opacity-100 md:focus:opacity-100",
|
||||
"data-[state=open]:opacity-100",
|
||||
"transition-opacity"
|
||||
)}
|
||||
disabled={isDeletingNote}
|
||||
>
|
||||
{isDeletingNote ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{t("more_options") || "More options"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right" className="w-40">
|
||||
{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 focus:text-destructive"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<ActionIcon className="mr-2 h-4 w-4" />
|
||||
<span>
|
||||
{isDeletingNote && isDeleteAction
|
||||
? t("deleting") || "Deleting..."
|
||||
: action.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<SidebarMenuItem>
|
||||
{onAddNote ? (
|
||||
<SidebarMenuButton
|
||||
onClick={onAddNote}
|
||||
className="text-muted-foreground hover:text-sidebar-foreground text-xs"
|
||||
>
|
||||
<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">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{t("no_notes") || "No notes yet"}</span>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* All Notes Sheet */}
|
||||
{searchSpaceId && (
|
||||
<AllNotesSidebar
|
||||
open={isAllNotesSidebarOpen}
|
||||
onOpenChange={setIsAllNotesSidebarOpen}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onAddNote={onAddNote}
|
||||
onCloseMobileSidebar={() => setOpenMobile(false)}
|
||||
/>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof SidebarGroup>) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
// Memoize items to prevent unnecessary re-renders
|
||||
const memoizedItems = useMemo(() => items, [items]);
|
||||
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupLabel>{t("search_space")}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{memoizedItems.map((item, index) => (
|
||||
<SidebarMenuItem key={`${item.title}-${index}`}>
|
||||
{item.url === "#" ? (
|
||||
// Non-interactive display item (e.g., search space name)
|
||||
<div className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-xs text-sidebar-foreground">
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{item.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
// Interactive link item
|
||||
<SidebarMenuButton asChild size="sm" aria-label={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
||||
Page Usage
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="space-y-2 px-2 py-2">
|
||||
{isCollapsed ? (
|
||||
// Show only a compact progress indicator when collapsed
|
||||
<div className="flex justify-center">
|
||||
<Progress value={usagePercentage} className="h-2 w-8" />
|
||||
</div>
|
||||
) : (
|
||||
// Show full details when expanded
|
||||
<>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-2" />
|
||||
<a
|
||||
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
|
||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors pt-1"
|
||||
>
|
||||
<Mail className="h-3 w-3 flex-shrink-0" />
|
||||
<span>Contact to increase limits</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue