feat: implement search space deletion and fixed rback issues with shared chats

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-01-13 01:45:58 -08:00
parent fbeffd58fe
commit 25b9118306
22 changed files with 671 additions and 144 deletions

View file

@ -54,8 +54,12 @@ export const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request
useEffect(() => {
setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
surfsense_doc_ids: mentionedDocuments
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]);
@ -138,12 +142,14 @@ export const Composer: FC = () => {
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
const updated = prev.filter(
(doc) => !(doc.id === docId && doc.document_type === docType)
);
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
setMentionedDocumentIds({
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
@ -153,9 +159,7 @@ export const Composer: FC = () => {
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(
mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
);
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
@ -171,8 +175,12 @@ export const Composer: FC = () => {
);
const updated = [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});

View file

@ -100,7 +100,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Sync initial documents
useEffect(() => {
if (initialDocuments.length > 0) {
setMentionedDocs(new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d])));
setMentionedDocs(
new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
);
}
}, [initialDocuments]);

View file

@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts
const CITATION_REGEX = /[\[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g;
const CITATION_REGEX = /[[【]\u200B?citation:(doc-)?(\d+)\u200B?[\]】]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering

View file

@ -230,8 +230,12 @@ const Composer: FC = () => {
// Sync mentioned document IDs to atom for use in chat request
useEffect(() => {
setMentionedDocumentIds({
surfsense_doc_ids: mentionedDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
document_ids: mentionedDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
surfsense_doc_ids: mentionedDocuments
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: mentionedDocuments
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
}, [mentionedDocuments, setMentionedDocumentIds]);
@ -314,12 +318,14 @@ const Composer: FC = () => {
const handleDocumentRemove = useCallback(
(docId: number, docType?: string) => {
setMentionedDocuments((prev) => {
const updated = prev.filter(
(doc) => !(doc.id === docId && doc.document_type === docType)
);
const updated = prev.filter((doc) => !(doc.id === docId && doc.document_type === docType));
setMentionedDocumentIds({
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
@ -329,9 +335,7 @@ const Composer: FC = () => {
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
const existingKeys = new Set(
mentionedDocuments.map((d) => `${d.document_type}:${d.id}`)
);
const existingKeys = new Set(mentionedDocuments.map((d) => `${d.document_type}:${d.id}`));
const newDocs = documents.filter(
(doc) => !existingKeys.has(`${doc.document_type}:${doc.id}`)
);
@ -347,8 +351,12 @@ const Composer: FC = () => {
);
const updated = [...prev, ...uniqueNewDocs];
setMentionedDocumentIds({
surfsense_doc_ids: updated.filter((doc) => doc.document_type === "SURFSENSE_DOCS").map((doc) => doc.id),
document_ids: updated.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});

View file

@ -86,6 +86,11 @@ export function LayoutDataProvider({
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
// Delete search space dialog state
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
const [searchSpaceToDelete, setSearchSpaceToDelete] = useState<SearchSpace | null>(null);
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({
@ -169,27 +174,46 @@ export function LayoutDataProvider({
}, [router]);
const handleSearchSpaceSettings = useCallback(
(id: number) => {
router.push(`/dashboard/${id}/settings`);
(space: SearchSpace) => {
router.push(`/dashboard/${space.id}/settings`);
},
[router]
);
const handleDeleteSearchSpace = useCallback(
async (id: number) => {
await deleteSearchSpace({ id });
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
setSearchSpaceToDelete(space);
setShowDeleteSearchSpaceDialog(true);
}, []);
const confirmDeleteSearchSpace = useCallback(async () => {
if (!searchSpaceToDelete) return;
setIsDeletingSearchSpace(true);
try {
await deleteSearchSpace({ id: searchSpaceToDelete.id });
refetchSearchSpaces();
if (Number(searchSpaceId) === id && searchSpaces.length > 1) {
const remaining = searchSpaces.filter((s) => s.id !== id);
if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) {
const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id);
if (remaining.length > 0) {
router.push(`/dashboard/${remaining[0].id}/new-chat`);
}
} else if (searchSpaces.length === 1) {
router.push("/dashboard");
}
},
[deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router]
);
} catch (error) {
console.error("Error deleting search space:", error);
} finally {
setIsDeletingSearchSpace(false);
setShowDeleteSearchSpaceDialog(false);
setSearchSpaceToDelete(null);
}
}, [
searchSpaceToDelete,
deleteSearchSpace,
refetchSearchSpaces,
searchSpaceId,
searchSpaces,
router,
]);
const handleNavItemClick = useCallback(
(item: NavItem) => {
@ -284,6 +308,8 @@ export function LayoutDataProvider({
searchSpaces={searchSpaces}
activeSearchSpaceId={Number(searchSpaceId)}
onSearchSpaceSelect={handleSearchSpaceSelect}
onSearchSpaceDelete={handleSearchSpaceDeleteClick}
onSearchSpaceSettings={handleSearchSpaceSettings}
onAddSearchSpace={handleAddSearchSpace}
searchSpace={activeSearchSpace}
navItems={navItems}
@ -297,9 +323,9 @@ export function LayoutDataProvider({
onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings}
onManageMembers={handleManageMembers}
onUserSettings={handleUserSettings}
onSettings={handleSettings}
onManageMembers={handleManageMembers}
onUserSettings={handleUserSettings}
onLogout={handleLogout}
pageUsage={pageUsage}
breadcrumb={breadcrumb}
@ -354,6 +380,48 @@ export function LayoutDataProvider({
</DialogContent>
</Dialog>
{/* Delete Search Space Dialog */}
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>{t("delete_search_space")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteSearchSpaceDialog(false)}
disabled={isDeletingSearchSpace}
>
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteSearchSpace}
disabled={isDeletingSearchSpace}
className="gap-2"
>
{isDeletingSearchSpace ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* All Shared Chats Sidebar */}
<AllSharedChatsSidebar
open={isAllSharedChatsSidebarOpen}

View file

@ -12,6 +12,8 @@ interface IconRailProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void;
className?: string;
}
@ -20,6 +22,8 @@ export function IconRail({
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace,
className,
}: IconRailProps) {
@ -32,7 +36,13 @@ export function IconRail({
key={searchSpace.id}
name={searchSpace.name}
isActive={searchSpace.id === activeSearchSpaceId}
isShared={searchSpace.memberCount > 1}
isOwner={searchSpace.isOwner}
onClick={() => onSearchSpaceSelect(searchSpace.id)}
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(searchSpace) : undefined}
onSettings={
onSearchSpaceSettings ? () => onSearchSpaceSettings(searchSpace) : undefined
}
size="md"
/>
))}

View file

@ -1,12 +1,25 @@
"use client";
import { Settings, Trash2, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface SearchSpaceAvatarProps {
name: string;
isActive?: boolean;
isShared?: boolean;
isOwner?: boolean;
onClick?: () => void;
onDelete?: () => void;
onSettings?: () => void;
size?: "sm" | "md";
}
@ -45,32 +58,103 @@ function getInitials(name: string): string {
export function SearchSpaceAvatar({
name,
isActive,
isShared,
isOwner = true,
onClick,
onDelete,
onSettings,
size = "md",
}: SearchSpaceAvatarProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const bgColor = stringToColor(name);
const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
const tooltipContent = (
<div className="flex flex-col">
<span>{name}</span>
{isShared && (
<span className="text-xs text-muted-foreground">
{isOwner ? tCommon("owner") : tCommon("shared")}
</span>
)}
</div>
);
const avatarButton = (
<button
type="button"
onClick={onClick}
className={cn(
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
)}
style={{ backgroundColor: bgColor }}
>
{initials}
{/* Shared indicator badge */}
{isShared && (
<span
className={cn(
"absolute -top-1 -right-1 flex items-center justify-center rounded-full bg-blue-500 text-white shadow-sm",
size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"
)}
title={tCommon("shared")}
>
<Users className={cn(size === "sm" ? "h-2 w-2" : "h-2.5 w-2.5")} />
</span>
)}
</button>
);
// If delete or settings handlers are provided, wrap with context menu
if (onDelete || onSettings) {
return (
<ContextMenu>
<Tooltip>
<TooltipTrigger asChild>
<ContextMenuTrigger asChild>
<div className="inline-block">{avatarButton}</div>
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltipContent}
</TooltipContent>
</Tooltip>
<ContextMenuContent className="w-48">
{onSettings && (
<ContextMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</ContextMenuItem>
)}
{onSettings && onDelete && <ContextMenuSeparator />}
{onDelete && isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</ContextMenuItem>
)}
{onDelete && !isOwner && (
<ContextMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("leave")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
// No context menu needed
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center rounded-lg font-semibold text-white transition-all",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
)}
style={{ backgroundColor: bgColor }}
>
{initials}
</button>
</TooltipTrigger>
<TooltipTrigger asChild>{avatarButton}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{name}
{tooltipContent}
</TooltipContent>
</Tooltip>
);

View file

@ -14,6 +14,8 @@ interface LayoutShellProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
@ -46,6 +48,8 @@ export function LayoutShell({
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace,
searchSpace,
navItems,
@ -96,6 +100,8 @@ export function LayoutShell({
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace}
searchSpace={searchSpace}
navItems={navItems}
@ -133,6 +139,8 @@ export function LayoutShell({
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace}
/>
</div>

View file

@ -13,6 +13,8 @@ interface MobileSidebarProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
@ -48,6 +50,8 @@ export function MobileSidebar({
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace,
searchSpace,
navItems,
@ -94,7 +98,13 @@ export function MobileSidebar({
<SearchSpaceAvatar
name={space.name}
isActive={space.id === activeSearchSpaceId}
isShared={space.memberCount > 1}
isOwner={space.isOwner}
onClick={() => handleSearchSpaceSelect(space.id)}
onDelete={onSearchSpaceDelete ? () => onSearchSpaceDelete(space) : undefined}
onSettings={
onSearchSpaceSettings ? () => onSearchSpaceSettings(space) : undefined
}
size="md"
/>
</div>
@ -111,33 +121,33 @@ export function MobileSidebar({
</div>
</div>
{/* Sidebar Content */}
<div className="flex-1 overflow-hidden">
<Sidebar
searchSpace={searchSpace}
isCollapsed={false}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={() => {
onNewChat();
onOpenChange(false);
}}
onChatSelect={handleChatSelect}
onChatDelete={onChatDelete}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="w-full border-none"
/>
</div>
{/* Sidebar Content */}
<div className="flex-1 overflow-hidden">
<Sidebar
searchSpace={searchSpace}
isCollapsed={false}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
sharedChats={sharedChats}
activeChatId={activeChatId}
onNewChat={() => {
onNewChat();
onOpenChange(false);
}}
onChatSelect={handleChatSelect}
onChatDelete={onChatDelete}
onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="w-full border-none"
/>
</div>
</SheetContent>
</Sheet>
);

View file

@ -57,7 +57,9 @@ export const DocumentMentionPicker = forwardRef<
const scrollContainerRef = useRef<HTMLDivElement>(null);
// State for pagination
const [accumulatedDocuments, setAccumulatedDocuments] = useState<Pick<Document, "id" | "title" | "document_type">[]>([]);
const [accumulatedDocuments, setAccumulatedDocuments] = useState<
Pick<Document, "id" | "title" | "document_type">[]
>([]);
const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@ -128,7 +130,7 @@ export const DocumentMentionPicker = forwardRef<
useEffect(() => {
if (currentPage === 0) {
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
// Add SurfSense docs first (they appear at top)
if (surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) {
@ -139,7 +141,7 @@ export const DocumentMentionPicker = forwardRef<
});
}
}
// Add regular documents
if (debouncedSearch.trim()) {
if (searchedDocuments?.items) {
@ -152,7 +154,7 @@ export const DocumentMentionPicker = forwardRef<
setHasMore(documents.has_more);
}
}
setAccumulatedDocuments(combinedDocs);
}
}, [documents, searchedDocuments, surfsenseDocs, debouncedSearch, currentPage]);
@ -209,7 +211,9 @@ export const DocumentMentionPicker = forwardRef<
const actualDocuments = accumulatedDocuments;
const actualLoading =
((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) || isSurfsenseDocsLoading) && currentPage === 0;
((debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) ||
isSurfsenseDocsLoading) &&
currentPage === 0;
// Track already selected documents using unique key (document_type:id) to avoid ID collisions
const selectedKeys = useMemo(

View file

@ -0,0 +1,225 @@
"use client";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};