sidebar footer

This commit is contained in:
Nate Kelley 2025-03-01 21:00:19 -07:00
parent 6aefea9cb1
commit a46f74589b
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
20 changed files with 316 additions and 49 deletions

View File

@ -3,7 +3,6 @@ import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-essentials',
'@chromatic-com/storybook',
'@storybook/addon-interactions'

View File

@ -120,7 +120,6 @@
"@chromatic-com/storybook": "^3.2.5",
"@storybook/addon-essentials": "^8.6.2",
"@storybook/addon-interactions": "^8.6.2",
"@storybook/addon-onboarding": "^8.6.2",
"@storybook/blocks": "^8.6.2",
"@storybook/nextjs": "^8.6.2",
"@storybook/react": "^8.6.2",

View File

@ -22,7 +22,6 @@ export default async function Layout({
jwtToken: accessToken
});
// const { signOut } = useBusterSupabaseAuthMethods();
const pathname = headersList.get('x-next-pathname') as string;
const cookiePathname = cookies().get('x-next-pathname')?.value;
const newUserRoute = createBusterRoute({ route: BusterAppRoutes.NEW_USER });

View File

@ -7,6 +7,7 @@ import { BusterRoutes, createBusterRoute } from '@/routes';
import React, { useMemo } from 'react';
import { useUserConfigContextSelector } from '@/context/Users';
import { useAppLayoutContextSelector } from '@/context/BusterAppLayout';
import { SidebarUserFooter } from './SidebarUserFooter/SidebarUserFooter';
const accountItems: ISidebarGroup = {
label: 'Account',
@ -70,7 +71,14 @@ export const SidebarSettings: React.FC<{}> = React.memo(({}) => {
return items;
}, [isAdmin]);
return <Sidebar content={content} header={<SidebarSettingsHeader />} activeItem={currentRoute} />;
return (
<Sidebar
content={content}
header={<SidebarSettingsHeader />}
activeItem={currentRoute}
footer={<SidebarUserFooter />}
/>
);
});
SidebarSettings.displayName = 'SidebarSettings';

View File

@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react';
import { SidebarUserFooterComponent } from './SidebarUserFooter';
const meta: Meta<typeof SidebarUserFooterComponent> = {
title: 'Features/Sidebars/SidebarUserFooter',
component: SidebarUserFooterComponent,
parameters: {
layout: 'centered'
},
decorators: [
(Story) => (
<div className="bg-background-secondary h-screen w-[300px] p-4">
<Story />
</div>
)
],
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof SidebarUserFooterComponent>;
export const Default: Story = {
args: {
name: 'John Doe',
email: 'john.doe@example.com'
}
};
export const WithoutAvatar: Story = {
args: {
name: 'Jane Smith',
email: 'jane.smith@example.com'
}
};
export const LongName: Story = {
args: {
name: 'Alexander Bartholomew Christopherson III',
email: 'alexander@example.com'
}
};

View File

@ -0,0 +1,118 @@
'use client';
import React, { useMemo } from 'react';
import { BusterRoutes, createBusterRoute } from '@/routes';
import {
Gear,
Database,
UserGroup,
Book2,
Message,
Flag,
ArrowRightFromLine
} from '@/components/ui/icons/NucleoIconOutlined';
import { BUSTER_DOCS_URL } from '@/routes/externalRoutes';
import { type DropdownProps, Dropdown } from '@/components/ui/dropdown/Dropdown';
import { AvatarUserButton } from '@/components/ui/avatar/AvatarUserButton';
import { useUserConfigContextSelector } from '@/context/Users';
import { signOut } from '@/hooks/useBusterSupabaseAuthMethods';
export const SidebarUserFooter: React.FC<{}> = () => {
const user = useUserConfigContextSelector((x) => x.user);
if (!user) return null;
const signOutServer = signOut;
return (
<SidebarUserFooterComponent
signOut={signOutServer}
name={user?.name}
avatarUrl={''}
email={user?.email}
/>
);
};
export const SidebarUserFooterComponent: React.FC<{
name: string;
avatarUrl?: string;
email: string;
signOut: () => Promise<{ error: string }>;
}> = React.memo(({ name, avatarUrl, email, signOut }) => {
return (
<SidebarUserDropdown signOut={signOut}>
<div className="flex w-full">
<AvatarUserButton username={name} avatarUrl={avatarUrl} email={email} />
</div>
</SidebarUserDropdown>
);
});
SidebarUserFooterComponent.displayName = 'SidebarUserFooterComponent';
const topItems: DropdownProps['items'] = [
{
label: 'Settings',
value: 'setting',
icon: <Gear />,
link: createBusterRoute({
route: BusterRoutes.APP_SETTINGS_PROFILE
})
},
{
label: 'Datasources',
value: 'datasources',
link: createBusterRoute({
route: BusterRoutes.APP_SETTINGS_DATASOURCES
}),
icon: <Database />
},
{
label: 'Invite & manage members',
value: 'invite-manage-members',
icon: <UserGroup />,
link: createBusterRoute({
route: BusterRoutes.APP_SETTINGS_USERS
})
}
];
const SidebarUserDropdown: React.FC<{
children: React.ReactNode;
signOut: () => void;
}> = React.memo(({ children, signOut }) => {
const allItems: DropdownProps['items'] = useMemo(() => {
return [
...topItems,
{ type: 'divider' },
{
label: 'Docs',
value: 'docs',
link: BUSTER_DOCS_URL,
icon: <Book2 />
},
{
label: 'Contact support',
value: 'contact-support',
icon: <Message />
},
{
label: 'Leave feedback',
value: 'leave-feedback',
icon: <Flag />
},
{ type: 'divider' },
{
label: 'Logout',
value: 'logout',
onClick: signOut,
icon: <ArrowRightFromLine />
}
];
}, []);
return (
<Dropdown align="end" side="right" items={allItems}>
{children}
</Dropdown>
);
});

View File

@ -0,0 +1,3 @@
export * from './SidebarUserFooter/SidebarUserComponent';
export * from './SidebarSettings';
export * from './SidebarPrimary';

View File

@ -27,7 +27,7 @@ export const Avatar: React.FC<AvatarProps> = React.memo(
height: size
}}>
{image && <AvatarImage src={image} />}
<AvatarFallback className={cn(!hasName && 'border bg-white')}>
<AvatarFallback className={cn(!hasName && !image && 'border bg-white')}>
{nameLetters || <BusterAvatarFallback />}
</AvatarFallback>
</AvatarBase>

View File

@ -35,7 +35,7 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'bg-gray-light/70 text-background flex h-full w-full items-center justify-center rounded-full text-base',
'bg-gray-light/60 text-background flex h-full w-full items-center justify-center rounded-full text-base',
className
)}
{...props}

View File

@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AvatarUserButton } from './AvatarUserButton';
const defaultUser = {
username: 'John Doe',
avatarUrl: 'https://i.pravatar.cc/200',
email: 'john.doe@example.com'
};
const meta: Meta<typeof AvatarUserButton> = {
title: 'UI/Avatar/AvatarUserButton',
component: AvatarUserButton,
parameters: {
layout: 'fullscreen'
},
decorators: [
(Story) => (
<div className="bg-background-secondary h-screen w-[300px] p-4">
<Story />
</div>
)
],
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof AvatarUserButton>;
export const Default: Story = {
args: {
...defaultUser
}
};
export const WithCustomUser: Story = {
args: {
...defaultUser
}
};
export const WithOnlyUsername: Story = {
args: {
...defaultUser,
avatarUrl: undefined
}
};
export const WithClickHandler: Story = {
args: {
username: 'Click Me',
onClick: () => alert('User component clicked!')
}
};

View File

@ -0,0 +1,32 @@
import React from 'react';
import { Avatar } from './Avatar';
import { Text } from '../typography/Text';
import { ChevronExpandY } from '../icons';
export const AvatarUserButton = React.forwardRef<
HTMLDivElement,
{
username?: string;
avatarUrl?: string;
email?: string;
}
>(({ username, avatarUrl, email }, ref) => {
return (
<div
ref={ref}
className="hover:bg-item-hover active:bg-item-active flex w-full cursor-pointer items-center gap-x-2 rounded-md p-2">
<Avatar size={32} image={avatarUrl} name={username} />
<div className="flex flex-col gap-y-0.5">
<Text className="flex-grow">{username}</Text>
<Text size={'sm'} variant={'secondary'}>
{email}
</Text>
</div>
<div className="ml-auto">
<ChevronExpandY />
</div>
</div>
);
});
AvatarUserButton.displayName = 'AvatarUserButton';

View File

@ -22,6 +22,7 @@ import { useMemoizedFn } from 'ahooks';
import { cn } from '@/lib/classMerge';
import { Input } from '../inputs/Input';
import { useDebounceSearch } from '@/hooks';
import Link from 'next/link';
export interface DropdownItem {
label: React.ReactNode | string;
@ -82,7 +83,8 @@ export const Dropdown: React.FC<DropdownProps> = React.memo(
emptyStateText = 'No items found',
className,
footerContent,
...props
dir,
modal
}) => {
const { filteredItems, searchText, handleSearchChange } = useDebounceSearch({
items,
@ -126,7 +128,12 @@ export const Dropdown: React.FC<DropdownProps> = React.memo(
const dropdownItems = selectType === 'multiple' ? unselectedItems : filteredItems;
return (
<DropdownMenu open={open} defaultOpen={open} onOpenChange={onOpenChange} {...props}>
<DropdownMenu
open={open}
defaultOpen={open}
onOpenChange={onOpenChange}
dir={dir}
modal={modal}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
className={cn('max-w-72 min-w-44', className)}
@ -246,9 +253,12 @@ const DropdownItem: React.FC<
});
const isSubItem = items && items.length > 0;
const isSelectable = !!selectType && selectType !== 'none';
const NodeWrapper = isSelectable && link ? Link : React.Fragment;
const nodeProps = isSelectable && link ? { href: link! } : ({} as any);
const content = (
<>
<NodeWrapper {...nodeProps}>
{showIndex && <span className="text-gray-light">{index}</span>}
{icon && !loading && <span className="text-icon-color">{icon}</span>}
{loading && <CircleSpinnerLoader size={9} />}
@ -264,7 +274,7 @@ const DropdownItem: React.FC<
linkIcon={linkIcon}
/>
)}
</>
</NodeWrapper>
);
if (isSubItem) {

View File

@ -72,6 +72,7 @@ const DropdownMenuContent = React.forwardRef<
}
>(({ className, children, sideOffset = 4, footerContent, ...props }, ref) => {
const NodeWrapper = footerContent ? 'div' : React.Fragment;
const nodeWrapperProps = footerContent ? { className: 'p-2' } : {};
return (
<DropdownMenuPrimitive.Portal>
@ -80,7 +81,7 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(baseContentClass, 'shadow', footerContent && 'p-0', className)}
{...props}>
<NodeWrapper className={cn(footerContent && 'p-2')}>{children}</NodeWrapper>
<NodeWrapper {...nodeWrapperProps}>{children}</NodeWrapper>
{footerContent && <div className="border-t p-2">{footerContent}</div>}
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>

View File

@ -15,7 +15,7 @@ export const Sidebar: React.FC<SidebarProps> = React.memo(
))}
</div>
</div>
{footer && <div className="mt-auto bg-red-200 pt-5">{footer}</div>}
{footer && <div className="mt-auto mb-2 pt-5">{footer}</div>}
</div>
);
}

View File

@ -1,10 +1,9 @@
import React, { useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { createStyles } from 'antd-style';
import darkTheme from 'react-syntax-highlighter/dist/cjs/styles/prism/vsc-dark-plus';
import { TextPulseLoader } from '../..';
import lightTheme from './light';
import { AppCodeBlockWrapper } from './AppCodeBlockWrapper';
import { cn } from '@/lib/classMerge';
export const AppCodeBlock: React.FC<{
language?: string;
@ -57,18 +56,8 @@ export const AppCodeBlock: React.FC<{
});
AppCodeBlock.displayName = 'AppCodeBlock';
const useStyles = createStyles(({ token }) => ({
codeInlineWrapper: {
backgroundColor: token.controlItemBgActive,
borderRadius: token.borderRadiusSM,
border: `0.5px solid ${token.colorBorder}`,
fontSize: token.fontSize - 1
}
}));
const CodeInlineWrapper: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const { cx, styles } = useStyles();
return <code className={cx(styles.codeInlineWrapper, 'px-1')}>{children}</code>;
return <code className={cn('bg-item-active rounded-sm border text-sm', 'px-1')}>{children}</code>;
};

View File

@ -5,6 +5,7 @@ import { Text } from '@/components/ui/typography';
import { useMemoizedFn } from 'ahooks';
import { Button } from '@/components/ui/buttons';
import { Copy } from '@/components/ui/icons';
import { cn } from '@/lib/classMerge';
export const AppCodeBlockWrapper: React.FC<{
children: React.ReactNode;
@ -14,7 +15,6 @@ export const AppCodeBlockWrapper: React.FC<{
buttons?: React.ReactNode;
title?: string | React.ReactNode;
}> = React.memo(({ children, code, showCopyButton = true, language, buttons, title }) => {
const { cx, styles } = useStyles();
const { openSuccessMessage } = useBusterNotifications();
const copyCode = useMemoizedFn(() => {
@ -24,8 +24,12 @@ export const AppCodeBlockWrapper: React.FC<{
});
return (
<div className={cx(styles.container, 'max-h-fit')}>
<div className={cx(styles.containerHeader, 'flex items-center justify-between')}>
<div className={cn('overflow-hidden rounded border', 'max-h-fit')}>
<div
className={cn(
'bg-item-active border-border max-h-[32px] min-h-[32px] border-b p-1',
'flex items-center justify-between'
)}>
<Text className="pl-2">{title || language}</Text>
<div className="flex items-center space-x-1">
{showCopyButton && (
@ -49,21 +53,3 @@ export const AppCodeBlockWrapper: React.FC<{
);
});
AppCodeBlockWrapper.displayName = 'CodeBlockWrapper';
const useStyles = createStyles(({ token }) => ({
container: {
backgroundColor: token.colorBgBase,
margin: `0px 0px`,
border: `0.5px solid ${token.colorBorder}`,
borderRadius: `${token.borderRadiusLG}px`,
overflow: 'hidden'
},
containerHeader: {
borderBottom: `0.5px solid ${token.colorBorder}`,
padding: '4px',
backgroundColor: token.controlItemBgActive,
height: '32px',
minHeight: '32px',
maxHeight: '32px'
}
}));

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { JSX } from 'react';
import { ExtraProps } from 'react-markdown';
import { AppCodeBlock } from '../AppCodeBlock/AppCodeBlock';
import { TextPulseLoader } from '@/components/ui/loaders';
@ -95,7 +95,7 @@ export const CustomHeading: React.FC<
numberOfLineMarkdown: number;
} & ExtraPropsExtra
> = ({ level, children, markdown, ...rest }) => {
const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements;
const HeadingTag = `h${level}` as any;
return (
<HeadingTag>
{children}

View File

@ -5,7 +5,8 @@ function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: PREFETCH_STALE_TIME
staleTime: PREFETCH_STALE_TIME,
queryFn: () => Promise.resolve()
},
dehydrate: {
// include pending queries in dehydration

View File

@ -1,3 +1,5 @@
'use server';
import { createClient } from '@/context/Supabase/server';
import { redirect } from 'next/navigation';
import { BusterRoutes, createBusterRoute } from '@/routes/busterRoutes/busterRoutes';
@ -205,3 +207,27 @@ export const useBusterSupabaseAuthMethods = () => {
resetPasswordEmailSend
};
};
export const signOut = async () => {
const supabase = await createClient();
const queryClient = new QueryClient();
const { error } = await supabase.auth.signOut();
if (error) {
return { error: error.message };
}
setTimeout(() => {
Object.keys(Cookies.get()).forEach((cookieName) => {
Cookies.remove(cookieName);
});
queryClient.clear();
}, 650);
return redirect(
createBusterRoute({
route: BusterRoutes.AUTH_LOGIN
})
);
};

View File

@ -1 +1,2 @@
export const BUSTER_HOME_PAGE = 'https://buster.so';
export const BUSTER_DOCS_URL = 'https://docs.buster.so';