Redesign sidebar

This commit is contained in:
Hakan Shehu
2025-01-24 19:15:36 +01:00
parent 477a7716a8
commit 514974a891
15 changed files with 255 additions and 218 deletions

View File

@@ -57,6 +57,8 @@ const createWindow = async () => {
preload: path.join(__dirname, 'preload.js'),
},
autoHideMenuBar: true,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 5, y: 5 },
});
mainWindow.setMenuBarVisibility(false);

View File

@@ -1,4 +1,4 @@
import { Plus } from 'lucide-react';
import { SquarePen } from 'lucide-react';
import React from 'react';
import {
@@ -20,7 +20,7 @@ export const ChatCreatePopover = () => {
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Plus className="mr-2 size-4 cursor-pointer" />
<SquarePen className="mr-2 size-4 cursor-pointer" />
</PopoverTrigger>
<PopoverContent className="w-96 p-1">
<UserSearch

View File

@@ -1,152 +0,0 @@
import { Bell, Check, ChevronsUpDown, Plus, Settings } from 'lucide-react';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { ReadStateIndicator } from '@/renderer/components/layouts/read-state-indicator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/renderer/components/ui/dropdown-menu';
import { useAccount } from '@/renderer/contexts/account';
import { useRadar } from '@/renderer/contexts/radar';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
export const LayoutSidebarHeader = () => {
const workspace = useWorkspace();
const account = useAccount();
const navigate = useNavigate();
const radar = useRadar();
const [open, setOpen] = React.useState(false);
const { data } = useQuery({
type: 'workspace_list',
accountId: account.id,
});
const workspaces = data ?? [];
const otherWorkspaces = workspaces.filter((w) => w.id !== workspace.id);
const otherWorkspaceStates = otherWorkspaces.map((w) =>
radar.getWorkspaceState(w.accountId, w.id)
);
const importantCount = otherWorkspaceStates.reduce(
(acc, curr) => acc + curr.importantCount,
0
);
const hasUnseenChanges = otherWorkspaceStates.some((w) => w.hasUnseenChanges);
return (
<div className="p-2">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button className="flex w-full items-center gap-2 rounded-md p-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-10 text-sm focus-visible:outline-none focus-visible:ring-0">
<Avatar
id={workspace.id}
avatar={workspace.avatar}
name={workspace.name}
className="size-7 rounded-lg"
/>
<p className="flex-1 text-left text-sm leading-tight truncate font-semibold">
{workspace.name}
</p>
<ChevronsUpDown className="ml-auto size-4" />
<ReadStateIndicator
count={importantCount}
hasChanges={hasUnseenChanges}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-80 rounded-lg"
align="start"
side="right"
sideOffset={4}
>
<DropdownMenuItem key={workspace.id} className="p-0">
<div className="w-full flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar
className="h-8 w-8 rounded-lg"
id={workspace.id}
name={workspace.name}
avatar={workspace.avatar}
/>
<p className="flex-1 text-left text-sm leading-tight truncate font-semibold">
{workspace.name}
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 p-2"
onClick={() => {
workspace.openSettings();
}}
>
<Settings className="size-4" />
<p className="font-medium">Settings</p>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 p-2" disabled={true}>
<Bell className="size-4" />
<p className="font-medium">Notifications</p>
</DropdownMenuItem>
{otherWorkspaces.length > 0 && (
<React.Fragment>
<DropdownMenuSeparator />
<DropdownMenuLabel className="mb-1">Workspaces</DropdownMenuLabel>
{workspaces.map((workspaceItem) => {
const workspaceState = radar.getWorkspaceState(
workspaceItem.accountId,
workspaceItem.id
);
return (
<DropdownMenuItem
key={workspaceItem.id}
className="p-0"
onClick={() => {
navigate(`/${account.id}/${workspaceItem.id}`);
}}
>
<div className="w-full flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar
className="h-8 w-8 rounded-lg"
id={workspaceItem.id}
name={workspaceItem.name}
avatar={workspaceItem.avatar}
/>
<p className="flex-1 text-left text-sm leading-tight truncate font-normal">
{workspaceItem.name}
</p>
{workspaceItem.id === workspace.id ? (
<Check className="size-4" />
) : (
<ReadStateIndicator
count={workspaceState.importantCount}
hasChanges={workspaceState.hasUnseenChanges}
/>
)}
</div>
</DropdownMenuItem>
);
})}
</React.Fragment>
)}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
className="gap-2 p-2 text-muted-foreground hover:text-foreground"
onClick={() => {
navigate(`/${account.id}/create`);
}}
>
<Plus className="size-4" />
<p className="font-medium">Create workspace</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -1,17 +0,0 @@
import { LayoutSidebarChats } from '@/renderer/components/layouts/layout-sidebar-chats';
import { LayoutSidebarFooter } from '@/renderer/components/layouts/layout-sidebar-footer';
import { LayoutSidebarHeader } from '@/renderer/components/layouts/layout-sidebar-header';
import { LayoutSidebarSpaces } from '@/renderer/components/layouts/layout-sidebar-spaces';
export const LayoutSidebar = () => {
return (
<div className="flex h-screen min-h-screen max-h-screen w-64 min-w-64 flex-col bg-sidebar text-sidebar-foreground">
<LayoutSidebarHeader />
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto">
<LayoutSidebarSpaces />
<LayoutSidebarChats />
</div>
<LayoutSidebarFooter />
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { LayoutMain } from '@/renderer/components/layouts/layout-main';
import { LayoutModal } from '@/renderer/components/layouts/layout-modal';
import { LayoutSidebar } from '@/renderer/components/layouts/layout-sidebar';
import { Sidebar } from '@/renderer/components/layouts/sidebars/sidebar';
interface LayoutProps {
main?: string | null;
@@ -10,7 +10,7 @@ interface LayoutProps {
export const Layout = ({ main, modal }: LayoutProps) => {
return (
<div className="w-screen min-w-screen h-screen min-h-screen flex flex-row">
<LayoutSidebar />
<Sidebar />
<main className="h-full max-h-screen w-full min-w-128 flex-grow overflow-hidden bg-white">
{main && <LayoutMain entryId={main} />}
</main>

View File

@@ -1,10 +1,11 @@
import { ChatCreatePopover } from '@/renderer/components/chats/chat-create-popover';
import { ChatSidebarItem } from '@/renderer/components/chats/chat-sidebar-item';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
import { Header } from '@/renderer/components/ui/header';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { ChatSidebarItem } from '@/renderer/components/chats/chat-sidebar-item';
import { ChatCreatePopover } from '@/renderer/components/chats/chat-create-popover';
import { cn } from '@/shared/lib/utils';
export const LayoutSidebarChats = () => {
export const SidebarChats = () => {
const workspace = useWorkspace();
const { data } = useQuery({
@@ -18,15 +19,13 @@ export const LayoutSidebarChats = () => {
const chats = data ?? [];
return (
<div className="group/sidebar-chats flex w-full min-w-0 flex-col p-2">
<div className="flex items-center justify-between">
<div className="flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70">
Chats
</div>
<div className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar-chats:opacity-100 flex items-center justify-center p-0">
<div className="flex flex-col group/sidebar-spaces h-full px-2">
<Header>
<p className="font-medium text-muted-foreground flex-grow">Chats</p>
<div className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar-spaces:opacity-100 flex items-center justify-center p-0">
<ChatCreatePopover />
</div>
</div>
</Header>
<div className="flex w-full min-w-0 flex-col gap-1">
{chats.map((item) => (
<button

View File

@@ -1,4 +1,4 @@
import { Check, ChevronsUpDown, LogOut, Plus, Settings } from 'lucide-react';
import { Check, LogOut, Plus, Settings } from 'lucide-react';
import React from 'react';
import { useNavigate } from 'react-router-dom';
@@ -17,7 +17,7 @@ import { useRadar } from '@/renderer/contexts/radar';
import { useQuery } from '@/renderer/hooks/use-query';
import { AccountReadState } from '@/shared/types/radars';
export function LayoutSidebarFooter() {
export function SidebarMenuFooter() {
const account = useAccount();
const navigate = useNavigate();
const radar = useRadar();
@@ -34,32 +34,16 @@ export function LayoutSidebarFooter() {
accountStates[accountItem.id] = radar.getAccountState(accountItem.id);
}
const importantCount = Object.values(accountStates).reduce(
(acc, curr) => acc + curr.importantCount,
0
);
const hasUnseenChanges = Object.values(accountStates).some(
(state) => state.hasUnseenChanges
);
return (
<div className="p-2">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button className="flex w-full items-center gap-2 rounded-md p-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground h-10 text-sm focus-visible:outline-none focus-visible:ring-0">
<button className="flex w-full items-center justify-center">
<Avatar
className="h-7 w-7 rounded-lg"
id={account.id}
name={account.name}
avatar={account.avatar}
/>
<p className="flex-1 text-left text-sm leading-tight truncate font-semibold">
{account.name}
</p>
<ChevronsUpDown className="ml-auto size-4" />
<ReadStateIndicator
count={importantCount}
hasChanges={hasUnseenChanges}
className="size-10 rounded-lg shadow-md"
/>
</button>
</DropdownMenuTrigger>

View File

@@ -0,0 +1,134 @@
import { Bell, Check, Plus, Settings } from 'lucide-react';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { ReadStateIndicator } from '@/renderer/components/layouts/read-state-indicator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/renderer/components/ui/dropdown-menu';
import { useAccount } from '@/renderer/contexts/account';
import { useRadar } from '@/renderer/contexts/radar';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
export const SidebarMenuHeader = () => {
const workspace = useWorkspace();
const account = useAccount();
const navigate = useNavigate();
const radar = useRadar();
const [open, setOpen] = React.useState(false);
const { data } = useQuery({
type: 'workspace_list',
accountId: account.id,
});
const workspaces = data ?? [];
const otherWorkspaces = workspaces.filter((w) => w.id !== workspace.id);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button className="flex w-full items-center justify-center">
<Avatar
id={workspace.id}
avatar={workspace.avatar}
name={workspace.name}
className="size-10 rounded-lg shadow-md"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-80 rounded-lg"
align="start"
side="right"
sideOffset={4}
>
<DropdownMenuItem key={workspace.id} className="p-0">
<div className="w-full flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar
className="h-8 w-8 rounded-lg"
id={workspace.id}
name={workspace.name}
avatar={workspace.avatar}
/>
<p className="flex-1 text-left text-sm leading-tight truncate font-semibold">
{workspace.name}
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 p-2"
onClick={() => {
workspace.openSettings();
}}
>
<Settings className="size-4" />
<p className="font-medium">Settings</p>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 p-2" disabled={true}>
<Bell className="size-4" />
<p className="font-medium">Notifications</p>
</DropdownMenuItem>
{otherWorkspaces.length > 0 && (
<React.Fragment>
<DropdownMenuSeparator />
<DropdownMenuLabel className="mb-1">Workspaces</DropdownMenuLabel>
{workspaces.map((workspaceItem) => {
const workspaceState = radar.getWorkspaceState(
workspaceItem.accountId,
workspaceItem.id
);
return (
<DropdownMenuItem
key={workspaceItem.id}
className="p-0"
onClick={() => {
navigate(`/${account.id}/${workspaceItem.id}`);
}}
>
<div className="w-full flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar
className="h-8 w-8 rounded-lg"
id={workspaceItem.id}
name={workspaceItem.name}
avatar={workspaceItem.avatar}
/>
<p className="flex-1 text-left text-sm leading-tight truncate font-normal">
{workspaceItem.name}
</p>
{workspaceItem.id === workspace.id ? (
<Check className="size-4" />
) : (
<ReadStateIndicator
count={workspaceState.importantCount}
hasChanges={workspaceState.hasUnseenChanges}
/>
)}
</div>
</DropdownMenuItem>
);
})}
</React.Fragment>
)}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
className="gap-2 p-2 text-muted-foreground hover:text-foreground"
onClick={() => {
navigate(`/${account.id}/create`);
}}
>
<Plus className="size-4" />
<p className="font-medium">Create workspace</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,30 @@
import { cn } from '@/shared/lib/utils';
interface SidebarMenuIconProps {
icon: React.ComponentType<{ className?: string }>;
onClick: () => void;
isActive?: boolean;
}
export const SidebarMenuIcon = ({
icon: Icon,
onClick,
isActive = false,
}: SidebarMenuIconProps) => {
return (
<div
className={cn(
'w-10 h-10 flex items-center justify-center hover:cursor-pointer hover:bg-gray-200 rounded-md',
isActive ? 'bg-gray-200' : ''
)}
onClick={onClick}
>
<Icon
className={cn(
'size-5',
isActive ? 'text-primary' : 'text-muted-foreground'
)}
/>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import { LayoutGrid, MessageCircle } from 'lucide-react';
import { SidebarMenuIcon } from '@/renderer/components/layouts/sidebars/sidebar-menu-icon';
import { SidebarMenuHeader } from '@/renderer/components/layouts/sidebars/sidebar-menu-header';
import { SidebarMenuFooter } from '@/renderer/components/layouts/sidebars/sidebar-menu-footer';
interface SidebarMenuProps {
value: string;
onChange: (value: string) => void;
}
export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
return (
<div className="flex flex-col h-full w-[65px] min-w-[65px] items-center bg-slate-100">
<div className="h-8 w-full app-drag-region"></div>
<SidebarMenuHeader />
<div className="flex flex-col gap-1 mt-2 w-full p-2 items-center flex-grow">
<SidebarMenuIcon
icon={MessageCircle}
onClick={() => {
onChange('chats');
}}
isActive={value === 'chats'}
/>
<SidebarMenuIcon
icon={LayoutGrid}
onClick={() => {
onChange('spaces');
}}
isActive={value === 'spaces'}
/>
</div>
<SidebarMenuFooter />
</div>
);
};

View File

@@ -1,9 +1,10 @@
import { useQuery } from '@/renderer/hooks/use-query';
import { Header } from '@/renderer/components/ui/header';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { SpaceCreateButton } from '@/renderer/components/spaces/space-create-button';
import { SpaceSidebarItem } from '@/renderer/components/spaces/space-sidebar-item';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
export const LayoutSidebarSpaces = () => {
export const SidebarSpaces = () => {
const workspace = useWorkspace();
const canCreateSpace =
workspace.role !== 'guest' && workspace.role !== 'none';
@@ -20,18 +21,15 @@ export const LayoutSidebarSpaces = () => {
const spaces = data ?? [];
return (
<div className="group/sidebar-spaces flex w-full min-w-0 flex-col p-2">
<div className="flex items-center justify-between">
<div className="flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70">
Spaces
</div>
<div className="flex flex-col group/sidebar-spaces h-full px-2">
<Header>
<p className="font-medium text-muted-foreground flex-grow">Spaces</p>
{canCreateSpace && (
<div className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar-spaces:opacity-100 flex items-center justify-center p-0">
<SpaceCreateButton />
</div>
)}
</div>
</Header>
<div className="flex w-full min-w-0 flex-col gap-1">
{spaces.map((space) => (
<SpaceSidebarItem space={space} key={space.id} />

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { SidebarMenu } from '@/renderer/components/layouts/sidebars/sidebar-menu';
import { SidebarChats } from '@/renderer/components/layouts/sidebars/sidebar-chats';
import { SidebarSpaces } from '@/renderer/components/layouts/sidebars/sidebar-spaces';
export const Sidebar = () => {
const [menu, setMenu] = React.useState('spaces');
return (
<div className="flex h-screen min-h-screen max-h-screen w-80 min-w-80 flex-row bg-slate-50">
<SidebarMenu value={menu} onChange={setMenu} />
<div className="min-h-0 flex-grow overflow-auto">
{menu === 'spaces' && <SidebarSpaces />}
{menu === 'chats' && <SidebarChats />}
</div>
</div>
);
};

View File

@@ -105,3 +105,11 @@
.cm-editor.cm-focused {
outline: none;
}
.app-drag-region {
-webkit-app-region: drag;
}
.app-no-drag-region {
-webkit-app-region: no-drag;
}

View File

@@ -389,8 +389,6 @@ const processEmojisIntoDb = (database: SQLite.Database) => {
console.log(`Done processing emojis into database.`);
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const generateEmojis = async () => {
if (!fs.existsSync(WORK_DIR_PATH)) {
fs.mkdirSync(WORK_DIR_PATH);

View File

@@ -368,8 +368,6 @@ const processIconsIntoDb = (db: SQLite.Database) => {
console.log('Done processing icons into database.');
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const generateIcons = async () => {
if (!fs.existsSync(WORK_DIR_PATH)) {
fs.mkdirSync(WORK_DIR_PATH);