mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-29 10:56:24 +02:00
Merge branch 'dev' into implement-surfsense-docs-mentions
This commit is contained in:
commit
720b339702
27 changed files with 1404 additions and 1025 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { format } from "date-fns";
|
|||
import {
|
||||
ArchiveIcon,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
RotateCcwIcon,
|
||||
|
|
@ -15,7 +16,7 @@ import {
|
|||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -38,25 +39,24 @@ import {
|
|||
} from "@/lib/chat/thread-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AllChatsSidebarProps {
|
||||
interface AllPrivateChatsSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
export function AllChatsSidebar({
|
||||
export function AllPrivateChatsSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllChatsSidebarProps) {
|
||||
}: AllPrivateChatsSidebarProps) {
|
||||
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
|
||||
|
|
@ -72,12 +72,10 @@ export function AllChatsSidebar({
|
|||
|
||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||
|
||||
// Handle mounting for portal
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
|
|
@ -88,7 +86,6 @@ export function AllChatsSidebar({
|
|||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Lock body scroll when open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
|
|
@ -100,7 +97,6 @@ export function AllChatsSidebar({
|
|||
};
|
||||
}, [open]);
|
||||
|
||||
// Fetch all threads (when not searching)
|
||||
const {
|
||||
data: threadsData,
|
||||
error: threadsError,
|
||||
|
|
@ -111,7 +107,6 @@ export function AllChatsSidebar({
|
|||
enabled: !!searchSpaceId && open && !isSearchMode,
|
||||
});
|
||||
|
||||
// Search threads (when searching)
|
||||
const {
|
||||
data: searchData,
|
||||
error: searchError,
|
||||
|
|
@ -122,18 +117,41 @@ export function AllChatsSidebar({
|
|||
enabled: !!searchSpaceId && open && isSearchMode,
|
||||
});
|
||||
|
||||
// Handle thread navigation
|
||||
// Filter to only private chats (PRIVATE visibility or no visibility set)
|
||||
const { activeChats, archivedChats } = useMemo(() => {
|
||||
if (isSearchMode) {
|
||||
const privateSearchResults = (searchData ?? []).filter(
|
||||
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||
);
|
||||
return {
|
||||
activeChats: privateSearchResults.filter((t) => !t.archived),
|
||||
archivedChats: privateSearchResults.filter((t) => t.archived),
|
||||
};
|
||||
}
|
||||
|
||||
if (!threadsData) return { activeChats: [], archivedChats: [] };
|
||||
|
||||
const activePrivate = threadsData.threads.filter(
|
||||
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||
);
|
||||
const archivedPrivate = threadsData.archived_threads.filter(
|
||||
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||
);
|
||||
|
||||
return { activeChats: activePrivate, archivedChats: archivedPrivate };
|
||||
}, [threadsData, searchData, isSearchMode]);
|
||||
|
||||
const threads = showArchived ? archivedChats : activeChats;
|
||||
|
||||
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);
|
||||
|
|
@ -144,10 +162,8 @@ export function AllChatsSidebar({
|
|||
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);
|
||||
|
|
@ -162,7 +178,6 @@ export function AllChatsSidebar({
|
|||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
|
||||
);
|
||||
|
||||
// Handle thread archive/unarchive
|
||||
const handleToggleArchive = useCallback(
|
||||
async (threadId: number, currentlyArchived: boolean) => {
|
||||
setArchivingThreadId(threadId);
|
||||
|
|
@ -186,25 +201,15 @@ export function AllChatsSidebar({
|
|||
[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;
|
||||
const activeCount = activeChats.length;
|
||||
const archivedCount = archivedChats.length;
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
|
|
@ -212,32 +217,32 @@ export function AllChatsSidebar({
|
|||
<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"
|
||||
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"
|
||||
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"}
|
||||
aria-label={t("chats") || "Private Chats"}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -249,7 +254,6 @@ export function AllChatsSidebar({
|
|||
</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
|
||||
|
|
@ -273,9 +277,8 @@ export function AllChatsSidebar({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab toggle for active/archived (only show when not searching) */}
|
||||
{!isSearchMode && (
|
||||
<div className="flex-shrink-0 flex border-b mx-4">
|
||||
<div className="shrink-0 flex border-b mx-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(false)}
|
||||
|
|
@ -303,7 +306,6 @@ export function AllChatsSidebar({
|
|||
</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">
|
||||
|
|
@ -332,7 +334,6 @@ export function AllChatsSidebar({
|
|||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{/* Main clickable area for navigation */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -353,7 +354,6 @@ export function AllChatsSidebar({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu
|
||||
open={openDropdownId === thread.id}
|
||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
||||
|
|
@ -377,7 +377,7 @@ export function AllChatsSidebar({
|
|||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-[80]">
|
||||
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||
disabled={isArchiving}
|
||||
|
|
@ -420,11 +420,11 @@ export function AllChatsSidebar({
|
|||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<MessageCircleMore className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<Lock 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"}
|
||||
: t("no_chats") || "No private chats"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
Loader2,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
RotateCcwIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
Users,
|
||||
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 { 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 AllSharedChatsSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
export function AllSharedChatsSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllSharedChatsSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const {
|
||||
data: threadsData,
|
||||
error: threadsError,
|
||||
isLoading: isLoadingThreads,
|
||||
} = useQuery({
|
||||
queryKey: ["all-threads", searchSpaceId],
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId && open && !isSearchMode,
|
||||
});
|
||||
|
||||
const {
|
||||
data: searchData,
|
||||
error: searchError,
|
||||
isLoading: isLoadingSearch,
|
||||
} = useQuery({
|
||||
queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery],
|
||||
queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()),
|
||||
enabled: !!searchSpaceId && open && isSearchMode,
|
||||
});
|
||||
|
||||
// Filter to only shared chats (SEARCH_SPACE visibility)
|
||||
const { activeChats, archivedChats } = useMemo(() => {
|
||||
if (isSearchMode) {
|
||||
const sharedSearchResults = (searchData ?? []).filter(
|
||||
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||
);
|
||||
return {
|
||||
activeChats: sharedSearchResults.filter((t) => !t.archived),
|
||||
archivedChats: sharedSearchResults.filter((t) => t.archived),
|
||||
};
|
||||
}
|
||||
|
||||
if (!threadsData) return { activeChats: [], archivedChats: [] };
|
||||
|
||||
const activeShared = threadsData.threads.filter(
|
||||
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||
);
|
||||
const archivedShared = threadsData.archived_threads.filter(
|
||||
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||
);
|
||||
|
||||
return { activeChats: activeShared, archivedChats: archivedShared };
|
||||
}, [threadsData, searchData, isSearchMode]);
|
||||
|
||||
const threads = showArchived ? archivedChats : activeChats;
|
||||
|
||||
const handleThreadClick = useCallback(
|
||||
(threadId: number) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
|
||||
onOpenChange(false);
|
||||
onCloseMobileSidebar?.();
|
||||
},
|
||||
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
|
||||
);
|
||||
|
||||
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 (currentChatId === threadId) {
|
||||
onOpenChange(false);
|
||||
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]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
|
||||
const error = isSearchMode ? searchError : threadsError;
|
||||
|
||||
const activeCount = activeChats.length;
|
||||
const archivedCount = archivedChats.length;
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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("shared_chats") || "Shared Chats"}
|
||||
>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="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>
|
||||
)}
|
||||
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<Users 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_shared_chats") || "No shared chats"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Share a chat to collaborate with your team
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Menu } from "lucide-react";
|
||||
import { Menu, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import type {
|
||||
ChatItem,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
} from "../../types/layout.types";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { SearchSpaceAvatar } from "../icon-rail/SearchSpaceAvatar";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
interface MobileSidebarProps {
|
||||
|
|
@ -26,17 +18,13 @@ interface MobileSidebarProps {
|
|||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
|
|
@ -65,17 +53,13 @@ export function MobileSidebar({
|
|||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
sharedChats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onViewAllChats,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onNoteSelect,
|
||||
onNoteDelete,
|
||||
onAddNote,
|
||||
onViewAllNotes,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
|
|
@ -97,27 +81,37 @@ export function MobileSidebar({
|
|||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNoteSelect = (note: NoteItem) => {
|
||||
onNoteSelect(note);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="w-[320px] p-0 flex">
|
||||
<SheetContent side="left" className="w-[300px] p-0 flex flex-col">
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
|
||||
<div className="shrink-0 border-r bg-muted/40">
|
||||
<ScrollArea className="h-full">
|
||||
<IconRail
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
/>
|
||||
</ScrollArea>
|
||||
{/* Horizontal Search Spaces Rail */}
|
||||
<div className="shrink-0 border-b bg-muted/40 px-2 py-2 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-1 py-1 overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/20">
|
||||
{searchSpaces.map((space) => (
|
||||
<div key={space.id} className="shrink-0">
|
||||
<SearchSpaceAvatar
|
||||
name={space.name}
|
||||
isActive={space.id === activeSearchSpaceId}
|
||||
onClick={() => handleSearchSpaceSelect(space.id)}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAddSearchSpace}
|
||||
className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||
>
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="sr-only">Add search space</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
searchSpace={searchSpace}
|
||||
|
|
@ -125,6 +119,7 @@ export function MobileSidebar({
|
|||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={() => {
|
||||
onNewChat();
|
||||
|
|
@ -147,6 +142,17 @@ export function MobileSidebar({
|
|||
pageUsage={pageUsage}
|
||||
className="w-full border-none"
|
||||
/>
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
className="w-full border-none"
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, Loader2, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NoteListItemProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
isReindexing?: boolean;
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function NoteListItem({
|
||||
name,
|
||||
isActive,
|
||||
isReindexing,
|
||||
onClick,
|
||||
onDelete,
|
||||
}: NoteListItemProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<div className="group/item relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||
"[&>span:last-child]:truncate",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{isReindexing ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("more_options")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react";
|
||||
import { FolderOpen, MessageSquare, PenSquare } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ChatItem,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
} from "../../types/layout.types";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
import { NavSection } from "./NavSection";
|
||||
import { NoteListItem } from "./NoteListItem";
|
||||
import { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
|
|
@ -30,17 +22,13 @@ interface SidebarProps {
|
|||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
|
|
@ -57,17 +45,13 @@ export function Sidebar({
|
|||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
sharedChats = [],
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onViewAllChats,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onNoteSelect,
|
||||
onNoteDelete,
|
||||
onAddNote,
|
||||
onViewAllNotes,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
|
|
@ -140,7 +124,7 @@ export function Sidebar({
|
|||
<ScrollArea className="flex-1">
|
||||
{isCollapsed ? (
|
||||
<div className="flex flex-col items-center gap-2 py-2 w-[60px]">
|
||||
{chats.length > 0 && (
|
||||
{(chats.length > 0 || sharedChats.length > 0) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -150,52 +134,78 @@ export function Sidebar({
|
|||
onClick={() => onToggleCollapse?.()}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span className="sr-only">{t("recent_chats")}</span>
|
||||
<span className="sr-only">{t("chats")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("recent_chats")} ({chats.length})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{notes.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onToggleCollapse?.()}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="sr-only">{t("notes")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("notes")} ({notes.length})
|
||||
{t("chats")} ({chats.length + sharedChats.length})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 py-2 w-[240px]">
|
||||
{/* Shared Chats Section */}
|
||||
<SidebarSection
|
||||
title={t("recent_chats")}
|
||||
title={t("shared_chats")}
|
||||
defaultOpen={true}
|
||||
action={
|
||||
onViewAllChats && chats.length > 0 ? (
|
||||
onViewAllSharedChats ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onViewAllChats}
|
||||
onClick={onViewAllSharedChats}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("view_all_chats")}</TooltipContent>
|
||||
<TooltipContent side="top">
|
||||
{t("view_all_shared_chats") || "View all shared chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{sharedChats.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sharedChats.map((chat) => (
|
||||
<ChatListItem
|
||||
key={chat.id}
|
||||
name={chat.name}
|
||||
isActive={chat.id === activeChatId}
|
||||
onClick={() => onChatSelect(chat)}
|
||||
onDelete={() => onChatDelete?.(chat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_shared_chats")}</p>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* Private Chats Section */}
|
||||
<SidebarSection
|
||||
title={t("chats")}
|
||||
defaultOpen={true}
|
||||
action={
|
||||
onViewAllPrivateChats ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onViewAllPrivateChats}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("view_all_private_chats") || "View all private chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
|
|
@ -213,67 +223,7 @@ export function Sidebar({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_recent_chats")}</p>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection
|
||||
title={t("notes")}
|
||||
defaultOpen={true}
|
||||
action={
|
||||
onViewAllNotes && notes.length > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onViewAllNotes}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("view_all_notes")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
persistentAction={
|
||||
onAddNote && notes.length > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onAddNote}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("add_note")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{notes.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{notes.map((note) => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
name={note.name}
|
||||
isActive={note.id === activeNoteId}
|
||||
isReindexing={note.isReindexing}
|
||||
onClick={() => onNoteSelect(note)}
|
||||
onDelete={() => onNoteDelete?.(note)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : onAddNote ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddNote}
|
||||
className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("create_new_note")}
|
||||
</button>
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_notes")}</p>
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_chats")}</p>
|
||||
)}
|
||||
</SidebarSection>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
export { AllChatsSidebar } from "./AllChatsSidebar";
|
||||
export { AllNotesSidebar } from "./AllNotesSidebar";
|
||||
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { NoteListItem } from "./NoteListItem";
|
||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
export { Sidebar } from "./Sidebar";
|
||||
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue