refactor: unify sidebar state management in LayoutDataProvider to allow only one slide-out panel open at a time

This commit is contained in:
Anish Sarkar 2026-03-22 00:01:31 +05:30
parent 7d33a69e20
commit 020d806b1f
7 changed files with 333 additions and 258 deletions

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
@ -50,19 +50,21 @@ import {
import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface AllPrivateChatsSidebarProps {
open: boolean;
export interface AllPrivateChatsSidebarContentProps {
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onCloseMobileSidebar?: () => void;
}
export function AllPrivateChatsSidebar({
open,
interface AllPrivateChatsSidebarProps extends AllPrivateChatsSidebarContentProps {
open: boolean;
}
export function AllPrivateChatsSidebarContent({
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllPrivateChatsSidebarProps) {
}: AllPrivateChatsSidebarContentProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
@ -96,16 +98,6 @@ export function AllPrivateChatsSidebar({
const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
const {
data: threadsData,
error: threadsError,
@ -113,7 +105,7 @@ export function AllPrivateChatsSidebar({
} = useQuery({
queryKey: ["all-threads", searchSpaceId],
queryFn: () => fetchThreads(Number(searchSpaceId)),
enabled: !!searchSpaceId && open && !isSearchMode,
enabled: !!searchSpaceId && !isSearchMode,
});
const {
@ -123,7 +115,7 @@ export function AllPrivateChatsSidebar({
} = useQuery({
queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery],
queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()),
enabled: !!searchSpaceId && open && isSearchMode,
enabled: !!searchSpaceId && isSearchMode,
});
// Filter to only private chats (PRIVATE visibility or no visibility set)
@ -250,11 +242,7 @@ export function AllPrivateChatsSidebar({
const archivedCount = archivedChats.length;
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("chats") || "Private Chats"}
>
<>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center gap-2">
{isMobile && (
@ -530,6 +518,29 @@ export function AllPrivateChatsSidebar({
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function AllPrivateChatsSidebar({
open,
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllPrivateChatsSidebarProps) {
const t = useTranslations("sidebar");
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("chats") || "Private Chats"}
>
<AllPrivateChatsSidebarContent
onOpenChange={onOpenChange}
searchSpaceId={searchSpaceId}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
</SidebarSlideOutPanel>
);
}

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
@ -50,19 +50,21 @@ import {
import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface AllSharedChatsSidebarProps {
open: boolean;
export interface AllSharedChatsSidebarContentProps {
onOpenChange: (open: boolean) => void;
searchSpaceId: string;
onCloseMobileSidebar?: () => void;
}
export function AllSharedChatsSidebar({
open,
interface AllSharedChatsSidebarProps extends AllSharedChatsSidebarContentProps {
open: boolean;
}
export function AllSharedChatsSidebarContent({
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllSharedChatsSidebarProps) {
}: AllSharedChatsSidebarContentProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
@ -96,16 +98,6 @@ export function AllSharedChatsSidebar({
const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
const {
data: threadsData,
error: threadsError,
@ -113,7 +105,7 @@ export function AllSharedChatsSidebar({
} = useQuery({
queryKey: ["all-threads", searchSpaceId],
queryFn: () => fetchThreads(Number(searchSpaceId)),
enabled: !!searchSpaceId && open && !isSearchMode,
enabled: !!searchSpaceId && !isSearchMode,
});
const {
@ -123,7 +115,7 @@ export function AllSharedChatsSidebar({
} = useQuery({
queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery],
queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()),
enabled: !!searchSpaceId && open && isSearchMode,
enabled: !!searchSpaceId && isSearchMode,
});
// Filter to only shared chats (SEARCH_SPACE visibility)
@ -250,11 +242,7 @@ export function AllSharedChatsSidebar({
const archivedCount = archivedChats.length;
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("shared_chats") || "Shared Chats"}
>
<>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center gap-2">
{isMobile && (
@ -530,6 +518,29 @@ export function AllSharedChatsSidebar({
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function AllSharedChatsSidebar({
open,
onOpenChange,
searchSpaceId,
onCloseMobileSidebar,
}: AllSharedChatsSidebarProps) {
const t = useTranslations("sidebar");
return (
<SidebarSlideOutPanel
open={open}
onOpenChange={onOpenChange}
ariaLabel={t("shared_chats") || "Shared Chats"}
>
<AllSharedChatsSidebarContent
onOpenChange={onOpenChange}
searchSpaceId={searchSpaceId}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
</SidebarSlideOutPanel>
);
}

View file

@ -9,26 +9,27 @@ import { useAnnouncements } from "@/hooks/use-announcements";
import { useMediaQuery } from "@/hooks/use-media-query";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
interface AnnouncementsSidebarProps {
open: boolean;
export interface AnnouncementsSidebarContentProps {
onOpenChange: (open: boolean) => void;
onCloseMobileSidebar?: () => void;
}
export function AnnouncementsSidebar({
open,
interface AnnouncementsSidebarProps extends AnnouncementsSidebarContentProps {
open: boolean;
}
export function AnnouncementsSidebarContent({
onOpenChange,
onCloseMobileSidebar,
}: AnnouncementsSidebarProps) {
}: AnnouncementsSidebarContentProps) {
const isMobile = !useMediaQuery("(min-width: 640px)");
const { announcements, markAllRead } = useAnnouncements();
useEffect(() => {
if (!open) return;
markAllRead();
}, [open, markAllRead]);
}, [markAllRead]);
const body = (
return (
<div className="h-full flex flex-col">
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
@ -65,10 +66,19 @@ export function AnnouncementsSidebar({
</div>
</div>
);
}
export function AnnouncementsSidebar({
open,
onOpenChange,
onCloseMobileSidebar,
}: AnnouncementsSidebarProps) {
return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel="Announcements">
{body}
<AnnouncementsSidebarContent
onOpenChange={onOpenChange}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
</SidebarSlideOutPanel>
);
}

View file

@ -140,8 +140,7 @@ interface TabDataSource {
markAllAsRead: () => Promise<boolean>;
}
interface InboxSidebarProps {
open: boolean;
export interface InboxSidebarContentProps {
onOpenChange: (open: boolean) => void;
comments: TabDataSource;
status: TabDataSource;
@ -149,14 +148,17 @@ interface InboxSidebarProps {
onCloseMobileSidebar?: () => void;
}
export function InboxSidebar({
open,
interface InboxSidebarProps extends InboxSidebarContentProps {
open: boolean;
}
export function InboxSidebarContent({
onOpenChange,
comments,
status,
totalUnreadCount,
onCloseMobileSidebar,
}: InboxSidebarProps) {
}: InboxSidebarContentProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
@ -199,7 +201,7 @@ export function InboxSidebar({
},
}),
staleTime: 30 * 1000,
enabled: isSearchMode && open,
enabled: isSearchMode,
});
useEffect(() => {
@ -207,23 +209,13 @@ export function InboxSidebar({
}, []);
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 || !isMobile) return;
if (!isMobile) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalOverflow;
};
}, [open, isMobile]);
}, [isMobile]);
useEffect(() => {
if (activeTab !== "status") {
@ -239,7 +231,7 @@ export function InboxSidebar({
queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
staleTime: 60 * 1000,
enabled: open && activeTab === "status",
enabled: activeTab === "status",
});
const statusSourceOptions = useMemo(() => {
@ -327,7 +319,7 @@ export function InboxSidebar({
// Infinite scroll — uses active tab's pagination
useEffect(() => {
if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return;
if (!activeSource.hasMore || activeSource.loadingMore || isSearchMode) return;
const observer = new IntersectionObserver(
(entries) => {
@ -347,7 +339,7 @@ export function InboxSidebar({
}
return () => observer.disconnect();
}, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]);
}, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, isSearchMode]);
const handleItemClick = useCallback(
async (item: InboxItem) => {
@ -522,7 +514,7 @@ export function InboxSidebar({
const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
const inboxContent = (
return (
<>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
@ -546,7 +538,7 @@ export function InboxSidebar({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-7 w-7 rounded-full"
onClick={() => setFilterDrawerOpen(true)}
>
<ListFilter className="h-4 w-4 text-muted-foreground" />
@ -694,7 +686,7 @@ export function InboxSidebar({
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full">
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
@ -790,7 +782,7 @@ export function InboxSidebar({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-7 w-7 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
@ -803,7 +795,7 @@ export function InboxSidebar({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
className="h-7 w-7 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
@ -1051,10 +1043,27 @@ export function InboxSidebar({
</div>
</>
);
}
export function InboxSidebar({
open,
onOpenChange,
comments,
status,
totalUnreadCount,
onCloseMobileSidebar,
}: InboxSidebarProps) {
const t = useTranslations("sidebar");
return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
{inboxContent}
<InboxSidebarContent
onOpenChange={onOpenChange}
comments={comments}
status={status}
totalUnreadCount={totalUnreadCount}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
</SidebarSlideOutPanel>
);
}

View file

@ -1,9 +1,9 @@
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { AnnouncementsSidebar } from "./AnnouncementsSidebar";
export { AllPrivateChatsSidebar, AllPrivateChatsSidebarContent } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar, AllSharedChatsSidebarContent } from "./AllSharedChatsSidebar";
export { AnnouncementsSidebar, AnnouncementsSidebarContent } from "./AnnouncementsSidebar";
export { ChatListItem } from "./ChatListItem";
export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar } from "./InboxSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
export { PageUsageDisplay } from "./PageUsageDisplay";