Compare commits

...

17 Commits

Author SHA1 Message Date
Raunak Bhagat
852f7c6941 Merge branch 'main' into fix/chat-dragging 2025-05-21 18:05:11 -06:00
Raunak Bhagat
c8e675f6a2 Add group dragging 2025-05-21 18:04:15 -06:00
Raunak Bhagat
fab0896a86 Make props explicit 2025-05-21 18:02:57 -06:00
Raunak Bhagat
6e27448e79 Add dnd and sortable contexts 2025-05-21 17:13:54 -06:00
Raunak Bhagat
2ca2fe684f Remove commented out code 2025-05-21 16:48:40 -06:00
Raunak Bhagat
4dac1a271b Add ability to press escape and stop renaming 2025-05-21 16:44:09 -06:00
Raunak Bhagat
7048049cab Fix popover cancelling bug 2025-05-21 13:20:11 -06:00
Raunak Bhagat
b5f16477b2 Update weird popover disappearing bug 2025-05-21 12:32:42 -06:00
Raunak Bhagat
7c8b6264fb Add detection for when click is outside input
- this cancels the editing state
2025-05-21 12:22:53 -06:00
Raunak Bhagat
217253263f Add chat renaming + deleting 2025-05-21 12:14:04 -06:00
Raunak Bhagat
cb322e4d3d Update popover for chat-session-display 2025-05-21 12:13:23 -06:00
Raunak Bhagat
3dc82eef0a Add kebab on group hover 2025-05-21 09:40:18 -06:00
Raunak Bhagat
399a28ccfa Merge branch 'main' into fix/chat-dragging 2025-05-21 09:12:59 -06:00
Raunak Bhagat
849319fa4d Render folders and disable caret for empty folders 2025-05-20 15:37:00 -06:00
Raunak Bhagat
14db6b96f6 Refresh new chat-group creation input 2025-05-20 13:04:08 -06:00
Raunak Bhagat
3ce5e3cf6e Change UI; still working on draggability 2025-05-20 12:14:47 -06:00
Raunak Bhagat
bea6b24ffa Add basic stub for new pages-tab-section 2025-05-19 15:32:32 -06:00
10 changed files with 3161 additions and 496 deletions

1719
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,21 +22,22 @@
"@headlessui/tailwindcss": "^0.2.1",
"@phosphor-icons/react": "^2.0.8",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.6",
"@sentry/nextjs": "^8.50.0",
"@sentry/tracing": "^7.120.3",
"@stripe/stripe-js": "^4.6.0",
@@ -48,7 +49,7 @@
"@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.8",
"autoprefixer": "^10.4.14",
"class-variance-authority": "^0.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"cookies-next": "^5.1.0",

View File

@@ -1,20 +1,199 @@
import { useRouter } from "next/router";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import { ChatSession } from "../interfaces";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronRight } from "lucide-react";
import { ChatSessionDisplay } from "./ChatSessionDisplay";
import { useCallback, useEffect, useRef, useState } from "react";
import { FiEdit2, FiMoreHorizontal, FiTrash } from "react-icons/fi";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { DefaultDropdownElement } from "@/components/Dropdown";
import { Input } from "@/components/ui/input";
import Text from "@/components/ui/text";
import { Button } from "@/components/ui/button";
export const ChatGroup = ({
groupName,
toggled,
chatSessions,
}: {
groupName: string;
toggled: boolean;
export interface ChatGroupProps {
name: string;
chatSessions: ChatSession[];
}) => {
const router = useRouter();
expanded: boolean;
toggleExpanded: () => void;
selectedId: string | undefined;
editable?: boolean;
folderId?: number;
onEditFolder?: (folderId: number, newName: string) => void;
onDeleteFolder?: (folderId: number) => void;
}
return toggled ? (
<div>
<p>{groupName}</p>
</div>
) : null;
};
export default function ChatGroup({
name,
chatSessions,
expanded,
toggleExpanded,
selectedId,
editable = false,
folderId,
onEditFolder,
onDeleteFolder,
}: ChatGroupProps) {
const hasChatsToShow = chatSessions.length > 0;
const [hover, setHover] = useState(false);
const [editingFolder, setEditingFolder] = useState(false);
const [deletingFolder, setDeletingFolder] = useState(false);
const reset = () => {
setDeletingFolder(false);
setEditingFolder(false);
setHover(false);
};
const folderRenameRef = useRef<HTMLInputElement>(null);
const handleFolderRename = useCallback(
async (e: React.FormEvent<HTMLDivElement>) => {
e.preventDefault();
const newFolderName = folderRenameRef.current?.value;
if (newFolderName && folderId && onEditFolder) {
onEditFolder(folderId, newFolderName);
}
reset();
},
[]
);
useEffect(() => {
document.addEventListener("mousedown", (e) => {
if (
folderRenameRef.current &&
!folderRenameRef.current.contains(e.target as Node) &&
editingFolder
) {
reset();
}
});
}, [editingFolder, reset]);
return (
<SidebarMenu>
<Collapsible
className="group/collapsible"
open={hasChatsToShow && expanded}
>
<CollapsibleTrigger
asChild
onClick={toggleExpanded}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => {
setHover(false);
setDeletingFolder(false);
}}
>
<SidebarMenuButton
tooltip={name}
className={`flex flex-row ${editingFolder ? "h-fit" : ""}`}
>
{editingFolder ? (
<Input
placeholder="New Folder Name..."
className="flex focus-visible:ring-1"
ref={folderRenameRef}
defaultValue={name}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleFolderRename(e);
} else if (e.key === "Escape") {
reset();
}
}}
/>
) : (
<span className="flex flex-1">{name}</span>
)}
{editable && hover && !editingFolder && (
<Popover>
<PopoverTrigger asChild>
<FiMoreHorizontal size={16} />
</PopoverTrigger>
<PopoverContent
className={`p-0 ${
deletingFolder ? "w-[250px]" : "w-[150px]"
}`}
>
{deletingFolder ? (
<div className="p-4 flex flex-col gap-y-4">
<Text>Are you sure you want to delete this folder?</Text>
<div className="px-2 flex flex-1 flex-row justify-center gap-x-2">
<Button
variant="destructive"
onClick={() => {
if (folderId && onDeleteFolder) {
onDeleteFolder(folderId);
}
reset();
}}
>
Delete
</Button>
<Button onClick={reset}>Cancel</Button>
</div>
</div>
) : (
<>
<DefaultDropdownElement
name="Rename"
icon={FiEdit2}
onSelect={() => {
setEditingFolder(true);
setTimeout(() => {
folderRenameRef.current?.focus();
}, 0);
}}
/>
<DefaultDropdownElement
name="Delete"
icon={FiTrash}
onSelect={() => setDeletingFolder(true)}
/>
</>
)}
</PopoverContent>
</Popover>
)}
<ChevronRight
className={`ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 ${
hasChatsToShow ? "" : "opacity-25"
}`}
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{chatSessions.map((chatSession) => (
<SidebarMenuSubItem key={chatSession.name}>
<SidebarMenuSubButton asChild>
<ChatSessionDisplay
chatSession={chatSession}
isSelected={selectedId === chatSession.id}
/>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenu>
);
}

View File

@@ -19,14 +19,19 @@ import {
FiX,
} from "react-icons/fi";
import { DefaultDropdownElement } from "@/components/Dropdown";
import { Popover } from "@/components/popover/Popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ShareChatSessionModal } from "../modal/ShareChatSessionModal";
import { CHAT_SESSION_ID_KEY, FOLDER_ID_KEY } from "@/lib/drag/constants";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { DragHandle } from "@/components/table/DragHandle";
import { WarningCircle } from "@phosphor-icons/react";
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
import { useChatContext } from "@/components/context/ChatContext";
import Text from "@/components/ui/text";
import { Button } from "@/components/ui/button";
export function ChatSessionDisplay({
chatSession,
@@ -53,22 +58,13 @@ export function ChatSessionDisplay({
const settings = useContext(SettingsContext);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const chatSessionRef = useRef<HTMLDivElement>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const renamingRef = useRef<HTMLDivElement>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const { refreshChatSessions, refreshFolders } = useChatContext();
const isMobile = settings?.isMobile;
const handlePopoverOpenChange = useCallback(
(open: boolean) => {
setPopoverOpen(open);
if (!open) {
setIsDeleteModalOpen(false);
}
},
[isDeleteModalOpen]
);
const handleDeleteClick = useCallback(() => {
setIsDeleteModalOpen(true);
@@ -77,8 +73,8 @@ export function ChatSessionDisplay({
const handleCancelDelete = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDeleteModalOpen(false);
setPopoverOpen(false);
setIsDeleteModalOpen(false);
}, []);
const handleConfirmDelete = useCallback(
@@ -110,6 +106,7 @@ export function ChatSessionDisplay({
} else {
alert("Failed to rename chat session");
}
setPopoverOpen(false);
},
[chatSession.id, chatName, router]
);
@@ -148,33 +145,16 @@ export function ChatSessionDisplay({
);
};
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
// Prevent default touch behavior
event.preventDefault();
// Create a custom event to mimic drag start
const customEvent = new Event("dragstart", { bubbles: true });
(customEvent as any).dataTransfer = new DataTransfer();
(customEvent as any).dataTransfer.setData(
CHAT_SESSION_ID_KEY,
chatSession.id.toString()
);
(customEvent as any).dataTransfer.setData(
FOLDER_ID_KEY,
chatSession.folder_id?.toString() || ""
);
// Dispatch the custom event
event.currentTarget.dispatchEvent(customEvent);
};
return (
<>
{isShareModalVisible && (
<ShareChatSessionModal
chatSessionId={chatSession.id}
existingSharedStatus={chatSession.shared_status}
onClose={() => setIsShareModalVisible(false)}
onClose={() => {
setIsShareModalVisible(false);
setPopoverOpen(false);
}}
/>
)}
@@ -185,6 +165,8 @@ export function ChatSessionDisplay({
}}
onMouseLeave={() => {
setIsHovered(false);
setIsDeleteModalOpen(false);
setPopoverOpen(false);
}}
className="flex group items-center w-full relative"
key={chatSession.id}
@@ -202,14 +184,6 @@ export function ChatSessionDisplay({
draggable={!isMobile}
onDragStart={!isMobile ? handleDragStart : undefined}
>
<div
className={`${
isMobile ? "visible" : "invisible group-hover:visible"
} flex-none`}
onTouchStart={isMobile ? handleTouchStart : undefined}
>
<DragHandle size={16} className="w-3 ml-[4px] mr-[2px]" />
</div>
<BasicSelectable
padding="extra"
isHovered={isHovered}
@@ -218,59 +192,59 @@ export function ChatSessionDisplay({
selected={isSelected}
removeColors={isRenamingChat}
>
<>
<div
className={`flex ${
isRenamingChat ? "-mr-2" : ""
} text-text-dark text-sm leading-normal relative gap-x-2`}
>
{isRenamingChat ? (
<div className="flex items-center w-full" ref={renamingRef}>
<div className="flex-grow mr-2">
<input
ref={inputRef}
value={chatName}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onChange={(e) => {
setChatName(e.target.value);
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
onRename();
event.preventDefault();
}
}}
className="w-full text-sm bg-transparent border-b border-text-darker outline-none"
/>
</div>
<div className="flex text-text-500 flex-none">
<button onClick={onRename} className="p-1">
<FiCheck size={14} />
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setChatName(chatSession.name);
<div
className={`flex ${
isRenamingChat ? "-mr-2" : ""
} text-text-dark text-sm leading-normal relative gap-x-2`}
>
{isRenamingChat ? (
<div className="flex items-center w-full" ref={renamingRef}>
<div className="flex-grow mr-2">
<input
ref={inputRef}
value={chatName}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onChange={(e) => {
setChatName(e.target.value);
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
onRename();
event.preventDefault();
} else if (event.key === "Escape") {
setIsRenamingChat(false);
setPopoverOpen(false);
}}
className="p-1"
>
<FiX size={14} />
</button>
</div>
}
}}
className="w-full text-sm bg-transparent border-b border-text-darker outline-none"
/>
</div>
) : (
<p className="break-all font-normal overflow-hidden dark:text-[#D4D4D4] whitespace-nowrap w-full mr-3 relative">
{chatName || `Unnamed Chat`}
<span
className={`absolute right-0 top-0 h-full w-2 bg-gradient-to-r from-transparent
<div className="flex text-text-500 flex-none">
<button onClick={onRename} className="p-1">
<FiCheck size={14} />
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setChatName(chatSession.name);
setIsRenamingChat(false);
}}
className="p-1"
>
<FiX size={14} />
</button>
</div>
</div>
) : (
<p className="break-all font-normal overflow-hidden dark:text-[#D4D4D4] whitespace-nowrap w-full mr-3 relative">
{chatName || `Unnamed Chat`}
<span
className={`absolute right-0 top-0 h-full w-2 bg-gradient-to-r from-transparent
${
isSelected
? "to-background-chat-selected"
@@ -278,115 +252,95 @@ export function ChatSessionDisplay({
? "to-background-chat-hover"
: "to-background-sidebar"
} `}
/>
</p>
)}
/>
</p>
)}
{!isRenamingChat && (
<div className="ml-auto my-auto justify-end flex z-30">
{!showShareModal && showRetentionWarning && (
<CustomTooltip
line
content={
<p>
This chat will expire{" "}
{daysUntilExpiration < 1
? "today"
: `in ${daysUntilExpiration} day${
daysUntilExpiration !== 1 ? "s" : ""
}`}
</p>
}
>
<div className="mr-1 hover:bg-black/10 p-1 -m-1 rounded z-50">
<WarningCircle className="text-warning" />
</div>
</CustomTooltip>
)}
{(isHovered || popoverOpen) && (
<div>
<div
onClick={(e) => {
e.preventDefault();
setPopoverOpen(!popoverOpen);
}}
className="-my-1"
>
<Popover
open={popoverOpen}
onOpenChange={handlePopoverOpenChange}
content={
<div className="p-1 rounded">
<FiMoreHorizontal
onClick={() => setPopoverOpen(true)}
size={16}
/>
</div>
}
popover={
<div
className={`border border-border text-text-dark rounded-lg bg-background z-50 ${
isDeleteModalOpen ? "w-64" : "w-32"
}`}
>
{!isDeleteModalOpen ? (
<>
{showShareModal && (
<DefaultDropdownElement
name="Share"
icon={FiShare2}
onSelect={() =>
showShareModal(chatSession)
}
/>
)}
{!search && (
<DefaultDropdownElement
name="Rename"
icon={FiEdit2}
onSelect={() => setIsRenamingChat(true)}
/>
)}
<DefaultDropdownElement
name="Delete"
icon={FiTrash}
onSelect={handleDeleteClick}
/>
</>
) : (
<div className="p-3">
<p className="text-sm mb-3">
Are you sure you want to delete this chat?
</p>
<div className="flex justify-center gap-2">
<button
className="px-3 py-1 text-sm bg-background-200 rounded"
onClick={handleCancelDelete}
>
Cancel
</button>
<button
className="px-3 py-1 text-sm bg-red-500 text-white rounded"
onClick={handleConfirmDelete}
>
Delete
</button>
</div>
</div>
)}
</div>
}
requiresContentPadding
sideOffset={6}
triggerMaxWidth
/>
</div>
{!isRenamingChat && (
<div className="ml-auto my-auto justify-end flex z-30">
{!showShareModal && showRetentionWarning && (
<CustomTooltip
line
content={
<p>
This chat will expire{" "}
{daysUntilExpiration < 1
? "today"
: `in ${daysUntilExpiration} day${
daysUntilExpiration !== 1 ? "s" : ""
}`}
</p>
}
>
<div className="mr-1 hover:bg-black/10 p-1 -m-1 rounded z-50">
<WarningCircle className="text-warning" />
</div>
)}
</div>
)}
</div>
</>
</CustomTooltip>
)}
{isHovered && (
<Popover open={popoverOpen}>
<PopoverTrigger
asChild
onClick={() => setPopoverOpen(!popoverOpen)}
>
<FiMoreHorizontal size={16} />
</PopoverTrigger>
<PopoverContent
className={`p-0 ${
isDeleteModalOpen ? "w-[250px]" : "w-[150px]"
}`}
>
{isDeleteModalOpen ? (
<div className="p-4 flex flex-col gap-y-4">
<Text>
Are you sure you want to delete this chat?
</Text>
<div className="px-2 flex flex-1 flex-row justify-center gap-x-2">
<Button
variant="destructive"
onClick={handleConfirmDelete}
>
Delete
</Button>
<Button onClick={handleCancelDelete}>
Cancel
</Button>
</div>
</div>
) : (
<>
{showShareModal && (
<DefaultDropdownElement
name="Share"
icon={FiShare2}
onSelect={() => showShareModal(chatSession)}
/>
)}
{!search && (
<DefaultDropdownElement
name="Rename"
icon={FiEdit2}
onSelect={() => {
setIsRenamingChat(true);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
/>
)}
<DefaultDropdownElement
name="Delete"
icon={FiTrash}
onSelect={handleDeleteClick}
/>
</>
)}
</PopoverContent>
</Popover>
)}
</div>
)}
</div>
</BasicSelectable>
</Link>
</div>

View File

@@ -10,10 +10,8 @@ import { Folder } from "../folders/interfaces";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useRouter } from "next/navigation";
import { FiPlus, FiCheck, FiX } from "react-icons/fi";
import { FolderDropdown } from "../folders/FolderDropdown";
import { ChatSessionDisplay } from "./ChatSessionDisplay";
import { useState, useCallback, useRef, useContext, useEffect } from "react";
import { Caret } from "@/components/icons/icons";
import { groupSessionsByDateRange } from "../lib";
import React from "react";
import {
@@ -22,7 +20,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { Search } from "lucide-react";
import { Expand, ListTree, Search } from "lucide-react";
import {
DndContext,
closestCenter,
@@ -43,21 +41,45 @@ import { CSS } from "@dnd-kit/utilities";
import { useChatContext } from "@/components/context/ChatContext";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { Separator } from "@/components/ui/separator";
import ChatGroup, { ChatGroupProps } from "./ChatGroup";
import {
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarProvider,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface SortableFolderProps {
folder: Folder;
children: React.ReactNode;
currentChatId?: string;
showShareModal?: (chatSession: ChatSession) => void;
showDeleteModal?: (chatSession: ChatSession) => void;
closeSidebar?: () => void;
onEdit: (folderId: number, newName: string) => void;
onDelete: (folderId: number) => void;
onDrop: (folderId: number, chatSessionId: string) => void;
index: number;
function ToolTipHelper({
icon,
toolTipContent,
onClick,
}: {
icon: React.ReactNode;
toolTipContent: string;
onClick?: () => void;
}) {
return (
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="border-0 border-red-50 px-2"
onClick={onClick}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent>{toolTipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
const SortableFolder: React.FC<SortableFolderProps> = (props) => {
function SortableChatGroup(props: ChatGroupProps) {
const settings = useContext(SettingsContext);
const mobile = settings?.isMobile;
const [isDragging, setIsDragging] = useState(false);
@@ -69,35 +91,28 @@ const SortableFolder: React.FC<SortableFolderProps> = (props) => {
transition,
isDragging: isDraggingDndKit,
} = useSortable({
id: props.folder.folder_id?.toString() ?? "",
id: props.folderId?.toString() ?? "",
disabled: mobile,
});
const ref = useRef<HTMLDivElement>(null);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1000 : "auto",
position: isDragging ? "relative" : "static",
opacity: isDragging ? 0.6 : 1,
};
useEffect(() => {
setIsDragging(isDraggingDndKit);
}, [isDraggingDndKit]);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
// const ref = useRef<HTMLDivElement>(null);
return (
<div
ref={setNodeRef}
className="pr-3 ml-4 overflow-visible flex items-start"
style={style}
{...attributes}
{...listeners}
>
<FolderDropdown ref={ref} {...props} />
<div style={style} {...listeners} ref={setNodeRef} {...attributes}>
<ChatGroup {...props} />
</div>
);
};
}
export function PagesTab({
existingChats,
@@ -140,34 +155,27 @@ export function PagesTab({
});
}
},
[router, setPopup, refreshChatSessions, refreshFolders]
[router, setPopup, refreshFolders]
);
const handleDeleteFolder = useCallback(
(folderId: number) => {
if (
confirm(
"Are you sure you want to delete this folder? This action cannot be undone."
)
) {
deleteFolder(folderId)
.then(() => {
router.refresh();
setPopup({
message: "Folder deleted successfully",
type: "success",
});
})
.catch((error: Error) => {
console.error("Failed to delete folder:", error);
setPopup({
message: `Failed to delete folder: ${error.message}`,
type: "error",
});
});
async (folderId: number) => {
try {
await deleteFolder(folderId);
setPopup({
message: "Folder deleted successfully",
type: "success",
});
await refreshFolders();
} catch (error: any) {
console.error("Failed to delete folder:", error);
setPopup({
message: `Failed to delete folder: ${(error as Error).message}`,
type: "error",
});
}
},
[router, setPopup]
[router, setPopup, refreshFolders]
);
const handleCreateFolder = useCallback(() => {
@@ -217,8 +225,6 @@ export function PagesTab({
existingChatsNotinFolders || []
);
const isHistoryEmpty = !existingChats || existingChats.length === 0;
const handleDrop = useCallback(
async (folderId: number, chatSessionId: string) => {
try {
@@ -320,158 +326,135 @@ export function PagesTab({
[folders]
);
const numberOfGroups = Object.entries(groupedChatSesssions).length;
const numberOfFolders = (folders ?? []).length;
const numberOfGroupsAndFolders = numberOfGroups + numberOfFolders;
const [expands, setExpands] = useState<boolean[]>(
Array(numberOfGroupsAndFolders).fill(false)
);
const toggleExpanded = (index: number) => {
const newExpands = Array.from(expands);
newExpands[index] = !newExpands[index];
setExpands(newExpands);
};
const expandAll = () => {
setExpands(Array(numberOfGroupsAndFolders).fill(true));
};
const collapseAll = () => {
setExpands(Array(numberOfGroupsAndFolders).fill(false));
};
return (
<div className="flex flex-col gap-y-2 flex-grow">
{popup}
<div className="px-4 mt-2 group mr-2 bg-background-sidebar dark:bg-transparent z-20">
<div className="flex group justify-between text-sm gap-x-2 text-text-300/80 items-center font-normal leading-normal">
<p>Chats</p>
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="my-auto mr-auto group-hover:opacity-100 opacity-0 transition duration-200 cursor-pointer gap-x-1 items-center text-black text-xs font-medium leading-normal mobile:hidden"
onClick={() => {
toggleChatSessionSearchModal?.();
}}
>
<Search
className="flex-none text-text-mobile-sidebar"
size={12}
/>
</button>
</TooltipTrigger>
<TooltipContent>Search Chats</TooltipContent>
</Tooltip>
</TooltipProvider>
<Separator className="mb-0" />
<button
onClick={handleCreateFolder}
className="flex group-hover:opacity-100 opacity-0 transition duration-200 cursor-pointer gap-x-1 items-center text-black text-xs font-medium leading-normal"
>
<FiPlus size={12} className="flex-none" />
Create Group
</button>
</div>
</div>
{isCreatingFolder ? (
<div className="px-4">
<div className="flex overflow-visible items-center w-full text-text-500 rounded-md p-1 relative">
<Caret size={16} className="flex-none mr-1" />
<input
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNewFolderSubmit(e);
}
}}
ref={newFolderInputRef}
type="text"
placeholder="Enter group name"
className="text-sm font-medium bg-transparent outline-none w-full pb-1 border-b border-background-500 transition-colors duration-200"
/>
<div className="flex -my-1">
<div
onClick={handleNewFolderSubmit}
className="cursor-pointer px-1"
>
<FiCheck size={14} />
</div>
<div
onClick={() => setIsCreatingFolder(false)}
className="cursor-pointer px-1"
>
<FiX size={14} />
</div>
<SidebarProvider>
<SidebarContent>
<SidebarGroup className="gap-y-2">
<div className="flex flex-row items-center">
<SidebarGroupLabel className="opacity-50 flex flex-1 border-0 border-red-50">
Chats
</SidebarGroupLabel>
<ToolTipHelper
icon={<Search className="opacity-50" />}
toolTipContent="Search through chats"
onClick={toggleChatSessionSearchModal}
/>
<ToolTipHelper
icon={<FiPlus className="opacity-50" />}
toolTipContent="Create new chat group"
onClick={handleCreateFolder}
/>
<ToolTipHelper
icon={<ListTree className="opacity-50" />}
toolTipContent="Collapse all folds"
onClick={collapseAll}
/>
<ToolTipHelper
icon={<Expand className="opacity-50" />}
toolTipContent="Expand all folds"
onClick={expandAll}
/>
</div>
</div>
</div>
) : (
<></>
)}
{folders && folders.length > 0 && (
<DndContext
modifiers={[restrictToVerticalAxis]}
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={folders.map((f) => f.folder_id?.toString() ?? "")}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{folders
.sort(
(a, b) =>
(a.display_priority ?? 0) - (b.display_priority ?? 0)
)
.map((folder, index) => (
<SortableFolder
key={folder.folder_id}
folder={folder}
currentChatId={currentChatId}
showShareModal={showShareModal}
showDeleteModal={showDeleteModal}
closeSidebar={closeSidebar}
onEdit={handleEditFolder}
onDelete={handleDeleteFolder}
onDrop={handleDrop}
index={index}
>
{folder.chat_sessions &&
folder.chat_sessions.map((chat) =>
renderChatSession(
chat,
folders != undefined && folders.length > 0
)
)}
</SortableFolder>
))}
</div>
</SortableContext>
</DndContext>
)}
<div className="pl-4 pr-3">
{!isHistoryEmpty && (
<>
{Object.entries(groupedChatSesssions)
.filter(([groupName, chats]) => chats.length > 0)
.map(([groupName, chats], index) => (
<FolderDropdown
key={groupName}
folder={{
folder_name: groupName,
chat_sessions: chats,
display_priority: 0,
{isCreatingFolder && (
<div className="py-2 px-2 flex flex-row justify-center items-center gap-x-4">
<Input
placeholder="New Chat Group..."
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNewFolderSubmit(e);
} else if (e.key === "Escape") {
setIsCreatingFolder(false);
}
}}
currentChatId={currentChatId}
showShareModal={showShareModal}
closeSidebar={closeSidebar}
onEdit={handleEditFolder}
onDrop={handleDrop}
index={folders ? folders.length + index : index}
>
{chats.map((chat) =>
renderChatSession(
chat,
folders != undefined && folders.length > 0
ref={newFolderInputRef}
type="text"
className="focus-visible:ring-1"
/>
<div className="flex flex-row justify-center items-center gap-x-2">
<div onClick={handleNewFolderSubmit}>
<FiCheck size={14} />
</div>
<FiX size={14} onClick={() => setIsCreatingFolder(false)} />
</div>
</div>
)}
<DndContext
modifiers={[restrictToVerticalAxis]}
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={(folders ?? []).map(
(folder) => folder.folder_id?.toString() ?? ""
)}
strategy={verticalListSortingStrategy}
>
{folders &&
folders
.sort(
(a, b) =>
(a.display_priority ?? 0) - (b.display_priority ?? 0)
)
)}
</FolderDropdown>
))}
</>
)}
{isHistoryEmpty && (!folders || folders.length === 0) && (
<p className="text-sm max-w-full mt-2 w-[250px]">
Try sending a message! Your chat history will appear here.
</p>
)}
</div>
.map((folder, index) => (
<SortableChatGroup
key={folder.folder_name}
name={folder.folder_name}
chatSessions={folder.chat_sessions}
expanded={expands[index]!}
toggleExpanded={() => toggleExpanded(index)}
selectedId={currentChatId}
editable
folderId={folder.folder_id!}
onEditFolder={handleEditFolder}
onDeleteFolder={handleDeleteFolder}
/>
))}
</SortableContext>
</DndContext>
<div className="pt-2">
<SidebarGroupLabel className="opacity-50 flex flex-1 border-0 border-red-50">
History
</SidebarGroupLabel>
</div>
{Object.entries(groupedChatSesssions).map(
([name, chats], index) => (
<ChatGroup
key={name}
name={name}
chatSessions={chats}
expanded={expands[numberOfFolders + index]!}
toggleExpanded={() => toggleExpanded(numberOfFolders + index)}
selectedId={currentChatId}
/>
)
)}
</SidebarGroup>
</SidebarContent>
</SidebarProvider>
</div>
);
}

View File

@@ -168,6 +168,14 @@
--background-dark: var(--off-white);
--new-background: var(--off-white);
--user-bubble: var(--off-white);
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
@@ -347,6 +355,14 @@
--agent-hovered: #f07c13;
--agent: #e47011;
--lighter-agent: #f59e0b;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}

View File

@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 dark:bg-neutral-950",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold text-neutral-950 dark:text-neutral-50",
className
)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,773 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
}
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-white dark:bg-neutral-950",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-white shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring dark:bg-neutral-950",
className
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-white shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))] dark:bg-neutral-950",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-neutral-100 dark:bg-neutral-800",
className
)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}