mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-25 17:42:41 +00:00
Compare commits
17 Commits
v2.12.9
...
fix/chat-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
852f7c6941 | ||
|
|
c8e675f6a2 | ||
|
|
fab0896a86 | ||
|
|
6e27448e79 | ||
|
|
2ca2fe684f | ||
|
|
4dac1a271b | ||
|
|
7048049cab | ||
|
|
b5f16477b2 | ||
|
|
7c8b6264fb | ||
|
|
217253263f | ||
|
|
cb322e4d3d | ||
|
|
3dc82eef0a | ||
|
|
399a28ccfa | ||
|
|
849319fa4d | ||
|
|
14db6b96f6 | ||
|
|
3ce5e3cf6e | ||
|
|
bea6b24ffa |
1719
web/package-lock.json
generated
1719
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
143
web/src/components/ui/sheet.tsx
Normal file
143
web/src/components/ui/sheet.tsx
Normal 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,
|
||||
};
|
||||
773
web/src/components/ui/sidebar.tsx
Normal file
773
web/src/components/ui/sidebar.tsx
Normal 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,
|
||||
};
|
||||
18
web/src/components/ui/skeleton.tsx
Normal file
18
web/src/components/ui/skeleton.tsx
Normal 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 };
|
||||
21
web/src/hooks/use-mobile.tsx
Normal file
21
web/src/hooks/use-mobile.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user