mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
Merge pull request #618 from MODSetter/dev
feat: removed All chats,notes sidebar sheet
This commit is contained in:
commit
18e55a7ed7
2 changed files with 485 additions and 369 deletions
|
|
@ -12,9 +12,11 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -25,14 +27,6 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import {
|
import {
|
||||||
|
|
@ -58,10 +52,39 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
||||||
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
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)
|
// Fetch all threads (when not searching)
|
||||||
const {
|
const {
|
||||||
data: threadsData,
|
data: threadsData,
|
||||||
|
|
@ -100,7 +123,6 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
||||||
try {
|
try {
|
||||||
await deleteThread(threadId);
|
await deleteThread(threadId);
|
||||||
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||||
// Invalidate queries to refresh the list
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
|
|
@ -158,197 +180,233 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
||||||
const activeCount = threadsData?.threads.length ?? 0;
|
const activeCount = threadsData?.threads.length ?? 0;
|
||||||
const archivedCount = threadsData?.archived_threads.length ?? 0;
|
const archivedCount = threadsData?.archived_threads.length ?? 0;
|
||||||
|
|
||||||
return (
|
if (!mounted) return null;
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent side="left" className="w-80 p-0 flex flex-col border-0">
|
|
||||||
<SheetHeader className="mx-3 px-4 pt-4 pb-0 space-y-2">
|
|
||||||
<SheetTitle>{t("all_chats") || "All Chats"}</SheetTitle>
|
|
||||||
<SheetDescription className="sr-only">
|
|
||||||
{t("all_chats_description") || "Browse and manage all your chats"}
|
|
||||||
</SheetDescription>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
return createPortal(
|
||||||
<div className="relative">
|
<AnimatePresence>
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
{open && (
|
||||||
<Input
|
<>
|
||||||
type="text"
|
{/* Backdrop */}
|
||||||
placeholder={t("search_chats") || "Search chats..."}
|
<motion.div
|
||||||
value={searchQuery}
|
initial={{ opacity: 0 }}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
animate={{ opacity: 1 }}
|
||||||
className="pl-9 pr-8 h-9 border-0 focus-visible:ring-0 focus-visible:border-0 shadow-none"
|
exit={{ opacity: 0 }}
|
||||||
/>
|
transition={{ duration: 0.2 }}
|
||||||
{searchQuery && (
|
className="fixed inset-0 z-50 bg-black/50"
|
||||||
<Button
|
onClick={() => onOpenChange(false)}
|
||||||
variant="ghost"
|
aria-hidden="true"
|
||||||
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>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
{/* Tab toggle for active/archived (only show when not searching) */}
|
{/* Panel */}
|
||||||
{!isSearchMode && (
|
<motion.div
|
||||||
<div className="flex border-b mx-3 -mt-3">
|
initial={{ x: "-100%" }}
|
||||||
<button
|
animate={{ x: 0 }}
|
||||||
type="button"
|
exit={{ x: "-100%" }}
|
||||||
onClick={() => setShowArchived(false)}
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
className={cn(
|
className="fixed inset-y-0 left-0 z-50 w-80 bg-background shadow-xl flex flex-col"
|
||||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
role="dialog"
|
||||||
!showArchived
|
aria-modal="true"
|
||||||
? "border-b-2 border-primary text-primary"
|
aria-label={t("all_chats") || "All Chats"}
|
||||||
: "text-muted-foreground hover:text-foreground"
|
>
|
||||||
)}
|
{/* Header */}
|
||||||
>
|
<div className="flex-shrink-0 p-4 pb-2 space-y-3">
|
||||||
Active ({activeCount})
|
<div className="flex items-center justify-between">
|
||||||
</button>
|
<h2 className="text-lg font-semibold">{t("all_chats") || "All Chats"}</h2>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
onClick={() => setShowArchived(true)}
|
size="icon"
|
||||||
className={cn(
|
className="h-8 w-8 rounded-full"
|
||||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
onClick={() => onOpenChange(false)}
|
||||||
showArchived
|
>
|
||||||
? "border-b-2 border-primary text-primary"
|
<X className="h-4 w-4" />
|
||||||
: "text-muted-foreground hover:text-foreground"
|
<span className="sr-only">Close</span>
|
||||||
)}
|
</Button>
|
||||||
>
|
|
||||||
Archived ({archivedCount})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0 overflow-hidden">
|
|
||||||
<div className="p-2">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
</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;
|
|
||||||
|
|
||||||
return (
|
{/* Search Input */}
|
||||||
<div
|
<div className="relative">
|
||||||
key={thread.id}
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
className={cn(
|
<Input
|
||||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
type="text"
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
placeholder={t("search_chats") || "Search chats..."}
|
||||||
"transition-colors cursor-pointer",
|
value={searchQuery}
|
||||||
isBusy && "opacity-50 pointer-events-none"
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
)}
|
className="pl-9 pr-8 h-9"
|
||||||
>
|
/>
|
||||||
{/* Main clickable area for navigation */}
|
{searchQuery && (
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="ghost"
|
||||||
<button
|
size="icon"
|
||||||
type="button"
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
onClick={handleClearSearch}
|
||||||
disabled={isBusy}
|
>
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
<X className="h-3.5 w-3.5" />
|
||||||
>
|
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||||
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
</Button>
|
||||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>
|
|
||||||
{t("updated") || "Updated"}:{" "}
|
|
||||||
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Actions dropdown */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6 shrink-0",
|
|
||||||
"opacity-0 group-hover:opacity-100 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">
|
|
||||||
<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>
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
{/* Scrollable Content */}
|
||||||
</SheetContent>
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
</Sheet>
|
{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;
|
||||||
|
|
||||||
|
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",
|
||||||
|
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>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 shrink-0",
|
||||||
|
"opacity-0 group-hover:opacity-100 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">
|
||||||
|
<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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
|
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -14,14 +16,6 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
|
|
@ -46,8 +40,37 @@ export function AllNotesSidebar({
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
|
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
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)
|
// Fetch all notes (when no search query)
|
||||||
const {
|
const {
|
||||||
data: notesData,
|
data: notesData,
|
||||||
|
|
@ -100,7 +123,6 @@ export function AllNotesSidebar({
|
||||||
search_space_id: noteSearchSpaceId,
|
search_space_id: noteSearchSpaceId,
|
||||||
note_id: noteId,
|
note_id: noteId,
|
||||||
});
|
});
|
||||||
// Invalidate queries to refresh the list
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] });
|
queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] });
|
||||||
|
|
@ -157,179 +179,215 @@ export function AllNotesSidebar({
|
||||||
});
|
});
|
||||||
}, [isSearchMode, searchData, notesData]);
|
}, [isSearchMode, searchData, notesData]);
|
||||||
|
|
||||||
return (
|
if (!mounted) return null;
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent side="left" className="w-80 p-0 flex flex-col border-0">
|
|
||||||
<SheetHeader className="mx-3 px-4 pt-4 pb-2 border-b space-y-2">
|
|
||||||
<SheetTitle>{t("all_notes") || "All Notes"}</SheetTitle>
|
|
||||||
<SheetDescription className="sr-only">
|
|
||||||
{t("all_notes_description") || "Browse and manage all your notes"}
|
|
||||||
</SheetDescription>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
return createPortal(
|
||||||
<div className="relative">
|
<AnimatePresence>
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
{open && (
|
||||||
<Input
|
<>
|
||||||
type="text"
|
{/* Backdrop */}
|
||||||
placeholder={t("search_notes") || "Search notes..."}
|
<motion.div
|
||||||
value={searchQuery}
|
initial={{ opacity: 0 }}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
animate={{ opacity: 1 }}
|
||||||
className="pl-9 pr-8 h-9 border-0 focus-visible:ring-0 focus-visible:border-0 shadow-none"
|
exit={{ opacity: 0 }}
|
||||||
/>
|
transition={{ duration: 0.2 }}
|
||||||
{searchQuery && (
|
className="fixed inset-0 z-50 bg-black/50"
|
||||||
<Button
|
onClick={() => onOpenChange(false)}
|
||||||
variant="ghost"
|
aria-hidden="true"
|
||||||
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>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 min-h-0 overflow-hidden">
|
{/* Panel */}
|
||||||
<div className="p-2">
|
<motion.div
|
||||||
{isLoading ? (
|
initial={{ x: "-100%" }}
|
||||||
<div className="flex items-center justify-center py-8">
|
animate={{ x: 0 }}
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
exit={{ x: "-100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
|
className="fixed inset-y-0 left-0 z-50 w-80 bg-background shadow-xl flex flex-col"
|
||||||
|
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 border-b">
|
||||||
|
<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>
|
</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;
|
|
||||||
|
|
||||||
return (
|
{/* Search Input */}
|
||||||
<div
|
<div className="relative">
|
||||||
key={note.id}
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
className={cn(
|
<Input
|
||||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
type="text"
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
placeholder={t("search_notes") || "Search notes..."}
|
||||||
"transition-colors cursor-pointer",
|
value={searchQuery}
|
||||||
isDeleting && "opacity-50 pointer-events-none"
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
)}
|
className="pl-9 pr-8 h-9"
|
||||||
>
|
/>
|
||||||
{/* Main clickable area for navigation */}
|
{searchQuery && (
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">{note.title}</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<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>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6 shrink-0",
|
|
||||||
"opacity-0 group-hover:opacity-100 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">
|
|
||||||
<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
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => {
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||||
onAddNote();
|
onClick={handleClearSearch}
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<X className="h-3.5 w-3.5" />
|
||||||
{t("create_new_note") || "Create a note"}
|
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* Footer with Add Note button */}
|
{/* Scrollable Content */}
|
||||||
{onAddNote && notes.length > 0 && (
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
<div className="mx-3 p-3">
|
{isLoading ? (
|
||||||
<Button
|
<div className="flex items-center justify-center py-8">
|
||||||
onClick={() => {
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
onAddNote();
|
</div>
|
||||||
onOpenChange(false);
|
) : error ? (
|
||||||
}}
|
<div className="text-center py-8 text-sm text-destructive">
|
||||||
className="w-full"
|
{t("error_loading_notes") || "Error loading notes"}
|
||||||
size="sm"
|
</div>
|
||||||
>
|
) : notes.length > 0 ? (
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<div className="space-y-1">
|
||||||
{t("create_new_note") || "Create a new note"}
|
{notes.map((note) => {
|
||||||
</Button>
|
const isDeleting = deletingNoteId === note.id;
|
||||||
</div>
|
|
||||||
)}
|
return (
|
||||||
</SheetContent>
|
<div
|
||||||
</Sheet>
|
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",
|
||||||
|
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>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 shrink-0",
|
||||||
|
"opacity-0 group-hover:opacity-100 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">
|
||||||
|
<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 border-t">
|
||||||
|
<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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue