Improve sidebar

This commit is contained in:
Hakan Shehu
2024-10-21 09:58:04 +02:00
parent 6edbdcc807
commit 3e479b83d0
34 changed files with 1446 additions and 264 deletions

View File

@@ -12,6 +12,7 @@
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
@@ -1791,6 +1792,51 @@
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.1.tgz",
"integrity": "sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",

View File

@@ -54,6 +54,7 @@
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",

View File

@@ -11,7 +11,7 @@ import {
import { Button } from '@/renderer/components/ui/button';
import { Spinner } from '@/renderer/components/ui/spinner';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
interface AccountLogoutProps {
id: string;

View File

@@ -12,7 +12,7 @@ import {
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { Icon } from '@/renderer/components/ui/icon';
import { Server } from '@/types/servers';
import { useMutation } from '@/renderer/hooks/use-mutation';

View File

@@ -12,7 +12,7 @@ import {
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { Icon } from '@/renderer/components/ui/icon';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { Server } from '@/types/servers';

View File

@@ -3,7 +3,7 @@ import { Button } from '@/renderer/components/ui/button';
import { Spinner } from '@/renderer/components/ui/spinner';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { useAccount } from '@/renderer/contexts/account';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
interface AvatarUploadProps {
onUpload: (id: string) => void;

View File

@@ -5,7 +5,7 @@ import { Button } from '@/renderer/components/ui/button';
import { Spinner } from '@/renderer/components/ui/spinner';
import { NodeCollaboratorRoleDropdown } from '@/renderer/components/collaborators/node-collaborator-role-dropdown';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { useWorkspace } from '@/renderer/contexts/workspace';
interface NodeCollaboratorCreate {

View File

@@ -19,7 +19,7 @@ export const NodeCollaboratorsPopover = ({
<PopoverTrigger asChild>
<Icon
name="user-add-line"
className="h-5 w-5 cursor-pointer text-muted-foreground"
className="h-4 w-4 cursor-pointer text-muted-foreground hover:text-foreground"
/>
</PopoverTrigger>
<PopoverContent className="mr-2 max-h-128 w-128 overflow-auto">

View File

@@ -25,7 +25,7 @@ import { cn } from '@/lib/utils';
import { Icon } from '@/renderer/components/ui/icon';
import { useDatabase } from '@/renderer/contexts/database';
import { FieldSelect } from '@/renderer/components/databases/fields/field-select';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { useWorkspace } from '@/renderer/contexts/workspace';

View File

@@ -11,7 +11,7 @@ import { Button } from '@/renderer/components/ui/button';
import { Label } from '@/renderer/components/ui/label';
import { Input } from '@/renderer/components/ui/input';
import { Spinner } from '@/renderer/components/ui/spinner';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { useMutation } from '@/renderer/hooks/use-mutation';
interface ServerCreateDialogProps {

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,138 @@
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cva, type VariantProps } from 'class-variance-authority';
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-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
{
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}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</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-foreground', 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-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,771 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from 'class-variance-authority';
import { useIsMobile } from '@/renderer/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/renderer/components/ui/button';
import { Input } from '@/renderer/components/ui/input';
import { Separator } from '@/renderer/components/ui/separator';
import { Sheet, SheetContent } from '@/renderer/components/ui/sheet';
import { Skeleton } from '@/renderer/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/renderer/components/ui/tooltip';
import { Icon } from '@/renderer/components/ui/icon';
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 SidebarContext = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a Sidebar.');
}
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)) => {
if (setOpenProp) {
return setOpenProp?.(
typeof value === 'function' ? value(open) : value,
);
}
_setOpen(value);
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; 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<SidebarContext>(
() => ({
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 text-sidebar-foreground has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full',
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(
'bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col',
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-[--sidebar-width] p-0 [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden 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 h-svh 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="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]: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}
>
<Icon name="layout-2-line" />
</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(
'hover:after:bg-sidebar-border 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] 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]:hover:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[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 min-h-svh flex-1 flex-col bg-background',
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] 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(
'focus-visible:ring-sidebar-ring h-8 w-full bg-background shadow-none focus-visible:ring-2',
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('bg-sidebar-border mx-2 w-auto', 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(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opa] 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(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform 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-background 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))]',
},
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(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform 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',
'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 &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 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(
'text-sidebar-foreground 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',
'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(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l 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(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 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',
'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,17 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-primary/10', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -7,7 +7,7 @@ import {
ToastTitle,
ToastViewport,
} from '@/renderer/components/ui/toast';
import { useToast } from '@/renderer/components/ui/use-toast';
import { useToast } from '@/renderer/hooks/use-toast';
export const Toaster = () => {
const { toasts } = useToast();

View File

@@ -3,6 +3,8 @@ import { getIdType, IdType } from '@/lib/id';
import { Breadcrumb } from '@/renderer/components/workspaces/containers/breadcrumb';
import { ChatBreadcrumb } from '@/renderer/components/workspaces/containers/chat-breadcrumb';
import { NodeCollaboratorsPopover } from '@/renderer/components/collaborators/node-collaborators-popover';
import { SidebarTrigger } from '@/renderer/components/ui/sidebar';
import { Separator } from '@/renderer/components/ui/separator';
interface ContainerHeaderProps {
nodeId: string;
@@ -11,13 +13,20 @@ interface ContainerHeaderProps {
export const ContainerHeader = ({ nodeId }: ContainerHeaderProps) => {
const idType = getIdType(nodeId);
return (
<div className="mx-1 flex h-12 items-center justify-between p-2 pr-4 text-foreground/80">
{idType === IdType.Chat ? (
<ChatBreadcrumb chatId={nodeId} />
) : (
<Breadcrumb nodeId={nodeId} />
)}
<NodeCollaboratorsPopover nodeId={nodeId} />
</div>
<header className="flex h-16 w-full shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex w-full items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="flex-grow">
{idType === IdType.Chat ? (
<ChatBreadcrumb chatId={nodeId} />
) : (
<Breadcrumb nodeId={nodeId} />
)}
</div>
<NodeCollaboratorsPopover nodeId={nodeId} />
</div>
</header>
);
};

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { SidebarChatNode } from '@/types/workspaces';
import { cn } from '@/lib/utils';
import { Avatar } from '@/renderer/components/avatars/avatar';
@@ -12,7 +11,6 @@ interface SidebarChatItemProps {
export const SidebarChatItem = ({
node,
}: SidebarChatItemProps): React.ReactNode => {
const workspace = useWorkspace();
const isActive = false;
const isUnread = false;
const directCount = 0;
@@ -21,12 +19,9 @@ export const SidebarChatItem = ({
<div
key={node.id}
className={cn(
'flex cursor-pointer items-center rounded-md p-1 text-sm text-foreground/80 hover:bg-gray-100',
isActive && 'bg-gray-100',
'flex w-full items-center',
isActive && 'bg-sidebar-accent',
)}
onClick={() => {
workspace.navigateToNode(node.id);
}}
>
<Avatar id={node.id} avatar={node.avatar} name={node.name} size="small" />
<span
@@ -38,7 +33,7 @@ export const SidebarChatItem = ({
{node.name ?? 'Unnamed'}
</span>
{directCount > 0 && (
<span className="mr-1 rounded-md bg-red-500 px-1 py-0.5 text-xs text-white">
<span className="bg-sidebar-accent text-sidebar-accent-foreground mr-1 rounded-md px-1 py-0.5 text-xs">
{directCount}
</span>
)}

View File

@@ -4,6 +4,15 @@ import { useQuery } from '@/renderer/hooks/use-query';
import { SidebarChatItem } from '@/renderer/components/workspaces/sidebars/sidebar-chat-item';
import { useWorkspace } from '@/renderer/contexts/workspace';
import {
SidebarGroup,
SidebarGroupAction,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/renderer/components/ui/sidebar';
export const SidebarChats = () => {
const workspace = useWorkspace();
const { data } = useQuery({
@@ -12,14 +21,24 @@ export const SidebarChats = () => {
});
return (
<div className="pt-2 first:pt-0">
<div className="flex items-center justify-between p-1 pb-2 text-xs text-muted-foreground">
<span>Chats</span>
<SidebarGroup className="group/sidebar-chats group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Chats</SidebarGroupLabel>
<SidebarGroupAction className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar-chats:opacity-100">
<ChatCreatePopover />
</div>
<div className="flex flex-col gap-0.5">
{data?.map((chat) => <SidebarChatItem key={chat.id} node={chat} />)}
</div>
</div>
</SidebarGroupAction>
<SidebarMenu>
{data?.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton
onClick={() => {
workspace.navigateToNode(item.id);
}}
>
<SidebarChatItem node={item} />
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
);
};

View File

@@ -0,0 +1,81 @@
import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/renderer/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/renderer/components/ui/sidebar';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useAccount } from '@/renderer/contexts/account';
import { Icon } from '@/renderer/components/ui/icon';
export function SidebarFooter() {
const account = useAccount();
const sidebar = useSidebar();
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar
className="h-8 w-8 rounded-lg"
id={account.id}
name={account.name}
avatar={account.avatar}
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{account.name}</span>
<span className="truncate text-xs">{account.email}</span>
</div>
<Icon name="arrow-down-s-line" className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar
className="h-8 w-8 rounded-lg"
id={account.id}
name={account.name}
avatar={account.avatar}
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{account.name}</span>
<span className="truncate text-xs">{account.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => {
account.logout();
}}
>
<Icon name="logout-circle-r-line" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,107 +1,104 @@
import React from 'react';
import { useWorkspace } from '@/renderer/contexts/workspace';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/renderer/components/ui/popover';
import { Icon } from '@/renderer/components/ui/icon';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useAccount } from '@/renderer/contexts/account';
import { useNavigate } from 'react-router-dom';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/renderer/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/renderer/components/ui/sidebar';
export const SidebarHeader = () => {
const workspace = useWorkspace();
const account = useAccount();
const navigate = useNavigate();
const [open, setOpen] = React.useState(false);
const sidebar = useSidebar();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="mb-1 flex h-12 cursor-pointer items-center justify-between border-b-2 border-gray-100 p-2 text-foreground/80 hover:bg-gray-200">
<div className="flex flex-grow items-center gap-2">
<Avatar
id={workspace.id}
name={workspace.name}
avatar={workspace.avatar}
className="h-7 w-7"
/>
<p className="flex-grow">{workspace.name}</p>
</div>
<Icon name="expand-up-down-line" />
</div>
</PopoverTrigger>
<PopoverContent align="start" className="flex w-96 flex-col gap-2 p-2">
<h2 className="text-sm font-semibold">Account</h2>
<div className="flex flex-grow items-start gap-2">
<Avatar
id={account.id}
name={account.name}
avatar={account.avatar}
className="mt-1 h-7 w-7"
/>
<div className="flex flex-grow flex-col">
<p>{account.name}</p>
<p className="text-xs text-muted-foreground">{account.email}</p>
</div>
</div>
<hr className="-mx-1 my-1 h-px bg-muted" />
<h2 className="text-sm font-semibold">Workspaces</h2>
<ul className="flex flex-col gap-0.5">
{account.workspaces.map((w) => {
return (
<li
key={w.id}
className="flex flex-row items-center gap-2 rounded-md p-2 pl-1 hover:cursor-pointer hover:bg-gray-100"
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-0"
>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<Avatar
id={workspace.id}
avatar={workspace.avatar}
name={workspace.name}
className="h-full w-full"
/>
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{workspace.name}</span>
<span className="truncate text-xs">Free Plan</span>
</div>
<Icon name="arrow-down-s-line" className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenuItem
className="gap-2 p-2 text-muted-foreground"
onClick={() => {
workspace.openSettings();
}}
>
<Icon name="settings-4-line" className="size-4" />
<p className="font-medium">Settings</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Workspaces
</DropdownMenuLabel>
{account.workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => {
navigate(`/${w.id}`);
setOpen(false);
navigate(`/${workspace.id}`);
}}
className="gap-2 p-2"
>
<Avatar
id={w.id}
name={w.name}
avatar={w.avatar}
className="h-7 w-7"
id={workspace.id}
avatar={workspace.avatar}
name={workspace.name}
size="small"
/>
<p>{w.name}</p>
</li>
);
})}
</ul>
<hr />
<div className="flex flex-col">
<button
className="flex flex-row items-center gap-2 rounded-md p-1 pl-0 text-sm outline-none hover:cursor-pointer hover:bg-gray-100"
onClick={() => {
navigate('/create');
}}
>
<Icon name="add-line" />
<span>Create workspace</span>
</button>
<button
className="flex flex-row items-center gap-2 rounded-md p-1 pl-0 text-sm outline-none hover:cursor-pointer hover:bg-gray-100"
onClick={() => {
workspace.openSettings();
}}
>
<Icon name="settings-4-line" />
<span>Settings</span>
</button>
<button
className="flex flex-row items-center gap-2 rounded-md p-1 pl-0 text-sm outline-none hover:cursor-pointer hover:bg-gray-100"
onClick={() => {
account.logout();
}}
>
<Icon name="logout-circle-r-line" />
<span>Logout</span>
</button>
</div>
</PopoverContent>
</Popover>
{workspace.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 p-2 text-muted-foreground"
onClick={() => {
navigate('/create');
}}
>
<Icon name="add-line" className="size-4" />
<p className="font-medium">Create workspace</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
};

View File

@@ -16,17 +16,19 @@ export const SidebarItem = ({ node }: SidebarItemProps): React.ReactNode => {
const directCount = 0;
return (
<div
<button
key={node.id}
className={cn(
'flex cursor-pointer items-center rounded-md p-1 text-sm text-foreground/80 hover:bg-gray-100',
isActive && 'bg-gray-100',
'flex w-full items-center',
isActive && 'bg-sidebar-accent',
)}
onClick={() => {
workspace.navigateToNode(node.id);
}}
>
<Avatar id={node.id} avatar={node.avatar} name={node.name} size="small" />
<Avatar
id={node.id}
avatar={node.avatar}
name={node.name}
className="h-4 w-4"
/>
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
@@ -36,7 +38,7 @@ export const SidebarItem = ({ node }: SidebarItemProps): React.ReactNode => {
{node.name ?? 'Unnamed'}
</span>
{directCount > 0 && (
<span className="mr-1 rounded-md bg-red-500 px-1 py-0.5 text-xs text-white">
<span className="bg-sidebar-accent text-sidebar-accent-foreground mr-1 rounded-md px-1 py-0.5 text-xs">
{directCount}
</span>
)}
@@ -46,6 +48,6 @@ export const SidebarItem = ({ node }: SidebarItemProps): React.ReactNode => {
className="mr-2 h-3 w-3 p-0.5 text-red-500"
/>
)}
</div>
</button>
);
};

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { Icon } from '@/renderer/components/ui/icon';
import {
@@ -17,6 +16,20 @@ import { PageCreateDialog } from '@/renderer/components/pages/page-create-dialog
import { DatabaseCreateDialog } from '@/renderer/components/databases/database-create-dialog';
import { SidebarSpaceNode } from '@/types/workspaces';
import { SidebarItem } from '@/renderer/components/workspaces/sidebars/sidebar-item';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/renderer/components/ui/collapsible';
import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/renderer/components/ui/sidebar';
import { useWorkspace } from '@/renderer/contexts/workspace';
interface SettingsState {
open: boolean;
@@ -28,7 +41,8 @@ interface SidebarSpaceNodeProps {
}
export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => {
const [isOpen, setIsOpen] = React.useState(true);
const workspace = useWorkspace();
const [openCreatePage, setOpenCreatePage] = React.useState(false);
const [openCreateChannel, setOpenCreateChannel] = React.useState(false);
const [openCreateDatabase, setOpenCreateDatabase] = React.useState(false);
@@ -41,88 +55,107 @@ export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => {
return (
<React.Fragment>
<div
<Collapsible
key={node.id}
className={cn(
'group/sidebar-node flex cursor-pointer items-center rounded-md p-1 text-sm text-foreground/80 hover:bg-gray-100',
isActive && 'bg-gray-100',
)}
asChild
defaultOpen={true}
className="group/collapsible"
>
<Avatar
id={node.id}
avatar={node.avatar}
name={node.name}
size="small"
/>
<span
className="line-clamp-1 w-full flex-grow pl-2 text-left"
onClick={() => setIsOpen(!isOpen)}
>
{node.name ?? 'Unnamed'}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span className="flex h-5 w-5 cursor-pointer items-center justify-center rounded-md p-1 opacity-0 hover:bg-gray-200 hover:text-primary group-hover/sidebar-node:opacity-100">
<Icon name="more-line" />
</span>
</DropdownMenuTrigger>
<DropdownMenuContent className="ml-1 w-72">
<DropdownMenuLabel>{node.name ?? 'Unnamed'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setOpenCreatePage(true)}>
<div className="flex flex-row items-center gap-2">
<Icon name={getDefaultNodeIcon(NodeTypes.Page)} />
<span>Add page</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setOpenCreateChannel(true)}>
<div className="flex flex-row items-center gap-2">
<Icon name={getDefaultNodeIcon(NodeTypes.Channel)} />
<span>Add channel</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setOpenCreateDatabase(true)}>
<div className="flex flex-row items-center gap-2">
<Icon name={getDefaultNodeIcon(NodeTypes.Database)} />
<span>Add database</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setOpenCreateFolder(true)}>
<div className="flex flex-row items-center gap-2">
<Icon name={getDefaultNodeIcon(NodeTypes.Folder)} />
<span>Add folder</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setSettingsState({ open: true })}>
<div className="flex flex-row items-center gap-2">
<Icon name="settings-3-line" />
<span>Settings</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setSettingsState({
open: true,
tab: 'collaborators',
})
}
>
<div className="flex flex-row items-center gap-2">
<Icon name="user-add-line" />
<span>Add collaborators</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isOpen && (
<div className="pl-4">
{node.children.map((child) => (
<SidebarItem key={child.id} node={child} />
))}
</div>
)}
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={node.name}>
<Icon
name="arrow-right-s-line"
className="transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
<Avatar
id={node.id}
avatar={node.avatar}
name={node.name}
className="h-4 w-4"
/>
<span>{node.name}</span>
</SidebarMenuButton>
</CollapsibleTrigger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="h-5 w-5 focus-visible:outline-none focus-visible:ring-0"
>
<Icon name="more-line" />
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent className="ml-1 w-72">
<DropdownMenuLabel>{node.name ?? 'Unnamed'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setOpenCreatePage(true)}>
<div className="flex flex-row items-center gap-2">
<Icon name={getDefaultNodeIcon(NodeTypes.Page)} />
<span>Add page</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setOpenCreateChannel(true)}>
<div className="flex flex-row items-center gap-2">
<Icon name={getDefaultNodeIcon(NodeTypes.Channel)} />
<span>Add channel</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setOpenCreateDatabase(true)}>
<div className="flex flex-row items-center gap-2">
<Icon name={getDefaultNodeIcon(NodeTypes.Database)} />
<span>Add database</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setOpenCreateFolder(true)}>
<div className="flex flex-row items-center gap-2">
<Icon name={getDefaultNodeIcon(NodeTypes.Folder)} />
<span>Add folder</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setSettingsState({ open: true })}
>
<div className="flex flex-row items-center gap-2">
<Icon name="settings-3-line" />
<span>Settings</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setSettingsState({
open: true,
tab: 'collaborators',
})
}
>
<div className="flex flex-row items-center gap-2">
<Icon name="user-add-line" />
<span>Add collaborators</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CollapsibleContent>
<SidebarMenuSub>
{node.children.map((child) => (
<SidebarMenuSubItem
key={child.id}
onClick={() => {
workspace.navigateToNode(child.id);
}}
>
<SidebarMenuSubButton>
<SidebarItem node={child} />
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
{openCreateChannel && (
<ChannelCreateDialog
spaceId={node.id}

View File

@@ -1,9 +1,16 @@
import React from 'react';
import { SpaceCreateButton } from '@/renderer/components/spaces/space-create-button';
import { SidebarSpaceItem } from '@/renderer/components/workspaces/sidebars/sidebar-space-item';
import { useQuery } from '@/renderer/hooks/use-query';
import { useWorkspace } from '@/renderer/contexts/workspace';
import {
SidebarGroup,
SidebarGroupAction,
SidebarGroupLabel,
SidebarMenu,
} from '@/renderer/components/ui/sidebar';
import { SpaceCreateButton } from '@/renderer/components/spaces/space-create-button';
export const SidebarSpaces = () => {
const workspace = useWorkspace();
const { data } = useQuery({
@@ -12,14 +19,14 @@ export const SidebarSpaces = () => {
});
return (
<div className="pt-2 first:pt-0">
<div className="flex items-center justify-between p-1 pb-2 text-xs text-muted-foreground">
<span>Spaces</span>
<SidebarGroup className="group/sidebar-spaces">
<SidebarGroupLabel>Spaces</SidebarGroupLabel>
<SidebarGroupAction className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar-spaces:opacity-100">
<SpaceCreateButton />
</div>
<div className="flex flex-col gap-0.5">
</SidebarGroupAction>
<SidebarMenu>
{data?.map((space) => <SidebarSpaceItem node={space} key={space.id} />)}
</div>
</div>
</SidebarMenu>
</SidebarGroup>
);
};

View File

@@ -1,25 +1,31 @@
import React from 'react';
import {
Sidebar as SidebarWrapper,
SidebarContent as SidebarContentWrapper,
SidebarFooter as SidebarFooterWrapper,
SidebarHeader as SidebarHeaderWrapper,
SidebarRail as SidebarRailWrapper,
} from '@/renderer/components/ui/sidebar';
import { SidebarHeader } from '@/renderer/components/workspaces/sidebars/sidebar-header';
import { SidebarSpaces } from '@/renderer/components/workspaces/sidebars/sidebar-spaces';
import { SidebarChats } from '@/renderer/components/workspaces/sidebars/sidebar-chats';
import { Icon } from '@/renderer/components/ui/icon';
import { SidebarFooter } from '@/renderer/components/workspaces/sidebars/sidebar-footer';
export const Sidebar = () => {
return (
<div className="h-full max-h-screen w-full border-r border-gray-200">
<SidebarHeader />
<div className="relative mt-2 max-h-full flex-grow overflow-hidden px-2">
<div className="flex cursor-pointer items-center rounded-md p-1 text-sm text-foreground/80 hover:bg-gray-100">
<Icon name="search-line" className="mr-2 h-4 w-4" />
<span>Search</span>
</div>
<div className="flex cursor-pointer items-center rounded-md p-1 text-sm text-foreground/80 hover:bg-gray-100">
<Icon name="inbox-line" className="mr-2 h-4 w-4" />
<span>Inbox</span>
</div>
<SidebarChats />
<SidebarWrapper collapsible="icon">
<SidebarHeaderWrapper>
<SidebarHeader />
</SidebarHeaderWrapper>
<SidebarContentWrapper>
<SidebarSpaces />
</div>
</div>
<SidebarChats />
</SidebarContentWrapper>
<SidebarFooterWrapper>
<SidebarFooter />
</SidebarFooterWrapper>
<SidebarRailWrapper />
</SidebarWrapper>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { useAccount } from '@/renderer/contexts/account';
import { useNavigate } from 'react-router-dom';

View File

@@ -14,7 +14,7 @@ import { Button } from '@/renderer/components/ui/button';
import { Spinner } from '@/renderer/components/ui/spinner';
import { Input } from '@/renderer/components/ui/input';
import { Textarea } from '@/renderer/components/ui/textarea';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { useAccount } from '@/renderer/contexts/account';
import { Avatar } from '@/renderer/components/avatars/avatar';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { WorkspaceForm } from './workspace-form';

View File

@@ -4,7 +4,7 @@ import { isValidEmail } from '@/lib/utils';
import { Button } from '@/renderer/components/ui/button';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { Spinner } from '@/renderer/components/ui/spinner';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { useWorkspace } from '@/renderer/contexts/workspace';
export const WorkspaceUserInvite = () => {

View File

@@ -9,7 +9,7 @@ import { WorkspaceRole } from '@/types/workspaces';
import { Spinner } from '@/renderer/components/ui/spinner';
import { Icon } from '@/renderer/components/ui/icon';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/components/ui/use-toast';
import { toast } from '@/renderer/hooks/use-toast';
import { useWorkspace } from '@/renderer/contexts/workspace';
interface WorkspaceRoleItem {

View File

@@ -10,6 +10,10 @@ import {
import { useAccount } from '@/renderer/contexts/account';
import { Modal } from '@/renderer/components/workspaces/modals/modal';
import { WorkspaceSettingsDialog } from '@/renderer/components/workspaces/workspace-settings-dialog';
import {
SidebarInset,
SidebarProvider,
} from '@/renderer/components/ui/sidebar';
export const Workspace = () => {
const { workspaceId } = useParams<{ workspaceId: string }>();
@@ -45,32 +49,32 @@ export const Workspace = () => {
},
}}
>
<div className="flex h-screen max-h-screen flex-row">
<div className="w-96">
<Sidebar />
</div>
<main className="h-full w-full min-w-128 flex-grow overflow-hidden bg-white">
<Outlet />
</main>
{modal && (
<Modal
nodeId={modal}
key={modal}
onClose={() => {
setSearchParams((prev) => {
prev.delete('modal');
return prev;
});
}}
<SidebarProvider>
<Sidebar />
<SidebarInset>
<main className="h-full max-h-screen w-full min-w-128 flex-grow overflow-hidden bg-white">
<Outlet />
</main>
{modal && (
<Modal
nodeId={modal}
key={modal}
onClose={() => {
setSearchParams((prev) => {
prev.delete('modal');
return prev;
});
}}
/>
)}
</SidebarInset>
{openSettings && (
<WorkspaceSettingsDialog
open={openSettings}
onOpenChange={setOpenSettings}
/>
)}
</div>
{openSettings && (
<WorkspaceSettingsDialog
open={openSettings}
onOpenChange={setOpenSettings}
/>
)}
</SidebarProvider>
</WorkspaceContext.Provider>
);
};

View File

@@ -0,0 +1,21 @@
import 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;
}

View File

@@ -29,6 +29,14 @@
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--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 {
@@ -56,6 +64,14 @@
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--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%;
}
}
@@ -81,4 +97,4 @@
.cm-editor.cm-focused {
outline: none;
}
}

View File

@@ -51,6 +51,16 @@ module.exports = {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
lg: 'var(--radius)',