mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-13 00:02:30 +10:00
Add sidebar
This commit is contained in:
@ -13,9 +13,11 @@
|
|||||||
"@hookform/resolvers": "^3.3.0",
|
"@hookform/resolvers": "^3.3.0",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.0.4",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-toast": "^1.1.4",
|
"@radix-ui/react-toast": "^1.1.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.6",
|
"@radix-ui/react-tooltip": "^1.0.6",
|
||||||
|
"@tabler/icons-react": "^2.32.0",
|
||||||
"@tanstack/react-query": "^4.33.0",
|
"@tanstack/react-query": "^4.33.0",
|
||||||
"@types/node": "20.4.8",
|
"@types/node": "20.4.8",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
|
|||||||
@ -1,18 +1,23 @@
|
|||||||
"use client"
|
'use client';
|
||||||
|
|
||||||
export default function Shell({ children }: {
|
import Sidebar from '@/components/sidebar/sidebar';
|
||||||
children: React.ReactNode
|
import TopBar from '@/components/sidebar/topbar';
|
||||||
}) {
|
|
||||||
|
|
||||||
|
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start min-h-screen">
|
<div className="flex justify-start min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
<div className="flex flex-col w-full overflow-hidden">
|
<div className="flex flex-col w-full overflow-hidden">
|
||||||
<main className="overflow-y-auto overscroll-none w-full p-8" style={{ height: "calc(100vh - 50px)" }}>
|
<TopBar />
|
||||||
|
|
||||||
|
<main
|
||||||
|
className="overflow-y-auto overscroll-none w-full p-8"
|
||||||
|
style={{ height: 'calc(100vh - 50px)' }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
37
frontend/src/components/sidebar/actions/sidebar-actions.tsx
Normal file
37
frontend/src/components/sidebar/actions/sidebar-actions.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconSearch,
|
||||||
|
IconSettings,
|
||||||
|
IconFilePlus,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export type NavigationMenuType = {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
isActive?: boolean;
|
||||||
|
onClick?: React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>;
|
||||||
|
};
|
||||||
|
export const navigationMenu: NavigationMenuType[] = [
|
||||||
|
{
|
||||||
|
label: 'Home',
|
||||||
|
path: '',
|
||||||
|
icon: <IconHome size={16} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Search',
|
||||||
|
path: '',
|
||||||
|
icon: <IconSearch size={16} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
path: '',
|
||||||
|
icon: <IconSettings size={16} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New Page',
|
||||||
|
path: '',
|
||||||
|
icon: <IconFilePlus size={16} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
5
frontend/src/components/sidebar/atoms/sidebar-atom.ts
Normal file
5
frontend/src/components/sidebar/atoms/sidebar-atom.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { atomWithWebStorage } from "@/lib/jotai-helper";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export const desktopSidebarAtom = atomWithWebStorage('showSidebar',true);
|
||||||
|
export const mobileSidebarAtom = atom(false);
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
export function useToggleSidebar(sidebarAtom) {
|
||||||
|
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
|
||||||
|
return () => {
|
||||||
|
setSidebarState(!sidebarState);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/components/sidebar/sidebar-section.tsx
Normal file
15
frontend/src/components/sidebar/sidebar-section.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SidebarSectionProps {
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSection({className, children}: SidebarSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex-shrink-0 flex-grow-0 pb-0.5', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/src/components/sidebar/sidebar.tsx
Normal file
62
frontend/src/components/sidebar/sidebar.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
desktopSidebarAtom,
|
||||||
|
mobileSidebarAtom,
|
||||||
|
} from '@/components/sidebar/atoms/sidebar-atom';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { IconFileText } from '@tabler/icons-react';
|
||||||
|
import { SidebarSection } from '@/components/sidebar/sidebar-section';
|
||||||
|
import {
|
||||||
|
navigationMenu,
|
||||||
|
NavigationMenuType,
|
||||||
|
} from '@/components/sidebar/actions/sidebar-actions';
|
||||||
|
import ButtonWithIcon from '@/components/ui/button-with-icon';
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [isSidebarOpen] = useAtom(
|
||||||
|
isMobile ? mobileSidebarAtom : desktopSidebarAtom
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className={`${
|
||||||
|
isSidebarOpen ? 'w-[270px]' : 'w-[0px]'
|
||||||
|
} flex-grow-0 flex-shrink-0 overflow-hidden border-r duration-300 ease-in-out`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col flex-shrink-0 gap-0.5 p-[10px]">
|
||||||
|
<div className="h-full">
|
||||||
|
<div className="mt-[20px]"></div>
|
||||||
|
|
||||||
|
<SidebarSection className="pb-2 mb-4 select-none border-b">
|
||||||
|
{navigationMenu.map((menu: NavigationMenuType, index: number) => (
|
||||||
|
<ButtonWithIcon
|
||||||
|
key={index}
|
||||||
|
icon={menu.icon}
|
||||||
|
variant={'ghost'}
|
||||||
|
className="w-full flex flex-1 justify-start items-center"
|
||||||
|
>
|
||||||
|
<span className="text-ellipsis overflow-hidden">
|
||||||
|
{menu.label}
|
||||||
|
</span>
|
||||||
|
</ButtonWithIcon>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[70vh]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ButtonWithIcon
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start"
|
||||||
|
icon={<IconFileText size={16} />}
|
||||||
|
>
|
||||||
|
Welcome page
|
||||||
|
</ButtonWithIcon>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/sidebar/topbar.tsx
Normal file
38
frontend/src/components/sidebar/topbar.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import {
|
||||||
|
desktopSidebarAtom,
|
||||||
|
mobileSidebarAtom,
|
||||||
|
} from '@/components/sidebar/atoms/sidebar-atom';
|
||||||
|
import { useToggleSidebar } from './hooks/use-toggle-sidebar';
|
||||||
|
import ButtonWithIcon from '../ui/button-with-icon';
|
||||||
|
import { IconLayoutSidebarLeftCollapse } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export default function TopBar() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const sidebarStateAtom = isMobile ? mobileSidebarAtom : desktopSidebarAtom;
|
||||||
|
|
||||||
|
const toggleSidebar = useToggleSidebar(sidebarStateAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="max-w-full z-50 select-none">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-full h-[50px] opacity-100 relative
|
||||||
|
transition-opacity duration-700 ease-in transition-color duration-700 ease-in"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center h-full overflow-hidden py-0 px-1 gap-2.5 border-b">
|
||||||
|
<div className="flex items-center leading-tight h-full flex-grow-0 mr-[8px] min-w-0 font-semibold text-sm">
|
||||||
|
<ButtonWithIcon
|
||||||
|
icon={<IconLayoutSidebarLeftCollapse size={20} />}
|
||||||
|
variant={'ghost'}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
></ButtonWithIcon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/components/ui/button-with-icon.tsx
Normal file
18
frontend/src/components/ui/button-with-icon.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ButtonIconProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = ButtonIconProps & React.ComponentPropsWithoutRef<typeof Button>;
|
||||||
|
|
||||||
|
export default function ButtonWithIcon({ icon, children, ...rest }: Props) {
|
||||||
|
return (
|
||||||
|
<Button {...rest} {...(children ? {} : { size: 'icon' })}>
|
||||||
|
<div className={`${children ? 'mr-[8px]' : ''}`}>{icon}</div>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,7 +24,7 @@ const buttonVariants = cva(
|
|||||||
default: "h-9 px-4 py-2",
|
default: "h-9 px-4 py-2",
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
lg: "h-10 rounded-md px-8",
|
lg: "h-10 rounded-md px-8",
|
||||||
icon: "h-9 w-9",
|
icon: "h-7 w-7",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
48
frontend/src/components/ui/scroll-area.tsx
Normal file
48
frontend/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@ -6,7 +6,6 @@ import { useAtom } from "jotai";
|
|||||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import { ILogin, IRegister } from "@/features/auth/types/auth.types";
|
import { ILogin, IRegister } from "@/features/auth/types/auth.types";
|
||||||
import { RESET } from "jotai/vanilla/utils/constants";
|
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -21,7 +20,7 @@ export default function useAuth() {
|
|||||||
try {
|
try {
|
||||||
const res = await login(data);
|
const res = await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
await setAuthToken(res.tokens);
|
setAuthToken(res.tokens);
|
||||||
|
|
||||||
router.push("/home");
|
router.push("/home");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -40,7 +39,7 @@ export default function useAuth() {
|
|||||||
const res = await register(data);
|
const res = await register(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
await setAuthToken(res.tokens);
|
setAuthToken(res.tokens);
|
||||||
|
|
||||||
router.push("/home");
|
router.push("/home");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -57,7 +56,7 @@ export default function useAuth() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await setAuthToken(RESET);
|
setAuthToken(null);
|
||||||
setCurrentUser('');
|
setCurrentUser('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
frontend/src/hooks/use-is-mobile.ts
Normal file
5
frontend/src/hooks/use-is-mobile.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||||
|
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery(`(max-width: 768px)`);
|
||||||
|
}
|
||||||
22
frontend/src/hooks/use-media-query.ts
Normal file
22
frontend/src/hooks/use-media-query.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
if (media.matches !== matches) {
|
||||||
|
setMatches(media.matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = () => {
|
||||||
|
setMatches(media.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
media.addEventListener('change', listener);
|
||||||
|
|
||||||
|
return () => media.removeEventListener('change', listener);
|
||||||
|
}, [matches, query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user