Implement workspace and account settings in sidebar (#130)

This commit is contained in:
Hakan Shehu
2025-07-17 13:02:02 +02:00
committed by GitHub
parent d23547b4d4
commit 4813d715ae
38 changed files with 745 additions and 528 deletions

View File

@@ -2,8 +2,8 @@ import { FastifyPluginCallback } from 'fastify';
import { accountRoutes } from '@colanode/server/api/client/routes/accounts';
import { avatarRoutes } from '@colanode/server/api/client/routes/avatars';
import { workspaceRoutes } from '@colanode/server/api/client/routes/workspaces';
import { socketRoutes } from '@colanode/server/api/client/routes/sockets';
import { workspaceRoutes } from '@colanode/server/api/client/routes/workspaces';
export const clientRoutes: FastifyPluginCallback = (instance, _, done) => {
instance.register(socketRoutes, { prefix: '/sockets' });

View File

@@ -1,8 +1,8 @@
import { defineConfig } from 'tsup';
import fs from 'fs/promises';
import path from 'path';
import { defineConfig } from 'tsup';
const copyTemplates = async () => {
const srcDir = path.resolve(__dirname, 'src/templates');
const destDir = path.resolve(__dirname, 'dist');

View File

@@ -23,7 +23,7 @@ export type ContainerMetadata = {
width?: number;
};
export type SidebarMenuType = 'chats' | 'spaces';
export type SidebarMenuType = 'chats' | 'spaces' | 'settings';
export type SidebarMetadata = {
menu: SidebarMenuType;
@@ -63,3 +63,12 @@ export type WorkspaceMetadataMap = {
'container.right': WorkspaceRightContainerMetadata;
'container.left': WorkspaceLeftContainerMetadata;
};
export enum SpecialContainerTabPath {
Downloads = 'downloads',
WorkspaceSettings = 'workspace/settings',
WorkspaceUsers = 'workspace/users',
WorkspaceDelete = 'workspace/delete',
AccountSettings = 'account/settings',
AccountLogout = 'account/logout',
}

View File

@@ -0,0 +1,19 @@
import { Button } from '@colanode/ui/components/ui/button';
export const AccountDelete = () => {
return (
<div className="flex items-center justify-between gap-6">
<div className="flex-1 space-y-2">
<h3 className="font-semibold">Delete account</h3>
<p className="text-sm text-muted-foreground">
Account delete will be available soon.
</p>
</div>
<div className="flex-shrink-0">
<Button variant="destructive" className="w-20" disabled>
Delete
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,29 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
} from '@colanode/ui/components/ui/breadcrumb';
import { useAccount } from '@colanode/ui/contexts/account';
export const AccountLogoutBreadcrumb = () => {
const account = useAccount();
return (
<Breadcrumb className="flex-grow">
<BreadcrumbList>
<BreadcrumbItem className="cursor-pointer hover:text-foreground">
<div className="flex items-center space-x-2">
<Avatar
id={account.id}
name={account.name}
avatar={account.avatar}
className="size-4"
/>
<span>{account.name} Logout</span>
</div>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -0,0 +1,18 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { useAccount } from '@colanode/ui/contexts/account';
export const AccountLogoutTab = () => {
const account = useAccount();
return (
<div className="flex items-center space-x-2">
<Avatar
id={account.id}
name={account.name}
avatar={account.avatar}
size="small"
/>
<span>{account.name} Logout</span>
</div>
);
};

View File

@@ -1,69 +1,67 @@
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@colanode/ui/components/ui/alert-dialog';
import { AccountLogoutBreadcrumb } from '@colanode/ui/components/accounts/account-logout-breadcrumb';
import { Button } from '@colanode/ui/components/ui/button';
import {
Container,
ContainerBody,
ContainerHeader,
} from '@colanode/ui/components/ui/container';
import { Separator } from '@colanode/ui/components/ui/separator';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useAccount } from '@colanode/ui/contexts/account';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
interface AccountLogoutProps {
onCancel: () => void;
onLogout: () => void;
}
export const AccountLogout = ({ onCancel, onLogout }: AccountLogoutProps) => {
export const AccountLogout = () => {
const account = useAccount();
const { mutate, isPending } = useMutation();
return (
<AlertDialog
open={true}
onOpenChange={(open) => {
if (!open) {
onCancel();
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want logout?</AlertDialogTitle>
<AlertDialogDescription>
All your data will be removed from this device.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
variant="destructive"
disabled={isPending}
className="cursor-pointer"
onClick={async () => {
mutate({
input: {
type: 'account.logout',
accountId: account.id,
},
onSuccess() {
onLogout();
},
onError(error) {
toast.error(error.message);
},
});
}}
>
{isPending && <Spinner className="mr-1" />}
Logout
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Container>
<ContainerHeader>
<AccountLogoutBreadcrumb />
</ContainerHeader>
<ContainerBody className="max-w-4xl">
<div className="space-y-8">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Logout</h2>
<Separator className="mt-3" />
</div>
<div className="flex items-center justify-between gap-6">
<div className="flex-1 space-y-2">
<h3 className="font-semibold">Sign out of your account</h3>
<p className="text-sm text-muted-foreground">
All your data will be removed from this device. If there are
pending changes, they will be lost. If you login again, all
the data will be re-synced.
</p>
</div>
<div className="flex-shrink-0">
<Button
variant="destructive"
disabled={isPending}
className="w-20 cursor-pointer"
onClick={async () => {
mutate({
input: {
type: 'account.logout',
accountId: account.id,
},
onError(error) {
toast.error(error.message);
},
});
}}
>
{isPending && <Spinner className="mr-1" />}
Logout
</Button>
</div>
</div>
</div>
</div>
</ContainerBody>
</Container>
);
};

View File

@@ -0,0 +1,29 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
} from '@colanode/ui/components/ui/breadcrumb';
import { useAccount } from '@colanode/ui/contexts/account';
export const AccountSettingsBreadcrumb = () => {
const account = useAccount();
return (
<Breadcrumb className="flex-grow">
<BreadcrumbList>
<BreadcrumbItem className="cursor-pointer hover:text-foreground">
<div className="flex items-center space-x-2">
<Avatar
id={account.id}
name={account.name}
avatar={account.avatar}
className="size-4"
/>
<span>{account.name} Settings</span>
</div>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -0,0 +1,18 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { useAccount } from '@colanode/ui/contexts/account';
export const AccountSettingsTab = () => {
const account = useAccount();
return (
<div className="flex items-center space-x-2">
<Avatar
id={account.id}
name={account.name}
avatar={account.avatar}
size="small"
/>
<span>{account.name} Settings</span>
</div>
);
};

View File

@@ -0,0 +1,43 @@
import { AccountDelete } from '@colanode/ui/components/accounts/account-delete';
import { AccountSettingsBreadcrumb } from '@colanode/ui/components/accounts/account-settings-breadcrumb';
import { AccountUpdate } from '@colanode/ui/components/accounts/account-update';
import {
Container,
ContainerBody,
ContainerHeader,
} from '@colanode/ui/components/ui/container';
import { Separator } from '@colanode/ui/components/ui/separator';
import { useAccount } from '@colanode/ui/contexts/account';
export const AccountSettings = () => {
const account = useAccount();
return (
<Container>
<ContainerHeader>
<AccountSettingsBreadcrumb />
</ContainerHeader>
<ContainerBody className="max-w-4xl">
<div className="space-y-8">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">General</h2>
<Separator className="mt-3" />
</div>
<AccountUpdate account={account} />
</div>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Danger Zone
</h2>
<Separator className="mt-3" />
</div>
<AccountDelete />
</div>
</div>
</ContainerBody>
</Container>
);
};

View File

@@ -162,6 +162,7 @@ export const AccountUpdate = ({ account }: { account: Account }) => {
<Button
type="submit"
disabled={isUpdatingAccount || isUploadingAvatar}
className="w-20"
>
{isUpdatingAccount && <Spinner className="mr-1" />}
Save

View File

@@ -1,8 +1,6 @@
import { useState } from 'react';
import { Account as AccountType } from '@colanode/client/types';
import { AccountLogout } from '@colanode/ui/components/accounts/account-logout';
import { AccountSettingsDialog } from '@colanode/ui/components/accounts/account-settings-dialog';
import { Workspace } from '@colanode/ui/components/workspaces/workspace';
import { WorkspaceCreate } from '@colanode/ui/components/workspaces/workspace-create';
import { AccountContext } from '@colanode/ui/contexts/account';
@@ -13,8 +11,6 @@ interface AccountProps {
}
export const Account = ({ account }: AccountProps) => {
const [openSettings, setOpenSettings] = useState(false);
const [openLogout, setOpenLogout] = useState(false);
const [openCreateWorkspace, setOpenCreateWorkspace] = useState(false);
const accountMetadataListQuery = useQuery({
@@ -59,8 +55,6 @@ export const Account = ({ account }: AccountProps) => {
<AccountContext.Provider
value={{
...account,
openSettings: () => setOpenSettings(true),
openLogout: () => setOpenLogout(true),
openWorkspaceCreate: () => setOpenCreateWorkspace(true),
openWorkspace: (id) => {
setOpenCreateWorkspace(false);
@@ -81,20 +75,6 @@ export const Account = ({ account }: AccountProps) => {
onCancel={handleWorkspaceCreateCancel}
/>
)}
{openSettings && (
<AccountSettingsDialog
open={true}
onOpenChange={() => setOpenSettings(false)}
/>
)}
{openLogout && (
<AccountLogout
onCancel={() => setOpenLogout(false)}
onLogout={() => {
setOpenLogout(false);
}}
/>
)}
</AccountContext.Provider>
);
};

View File

@@ -1,11 +1,13 @@
import { match } from 'ts-pattern';
import { ContainerTab } from '@colanode/client/types';
import { ContainerTab, SpecialContainerTabPath } from '@colanode/client/types';
import { getIdType, IdType } from '@colanode/core';
import { AccountLogout } from '@colanode/ui/components/accounts/account-logout';
import { AccountSettings } from '@colanode/ui/components/accounts/account-settings';
import { ChannelContainer } from '@colanode/ui/components/channels/channel-container';
import { ChatContainer } from '@colanode/ui/components/chats/chat-container';
import { DatabaseContainer } from '@colanode/ui/components/databases/database-container';
import { DownloadsList } from '@colanode/ui/components/downloads/downloads-list';
import { DownloadsContainer } from '@colanode/ui/components/downloads/downloads-container';
import { FileContainer } from '@colanode/ui/components/files/file-container';
import { FolderContainer } from '@colanode/ui/components/folders/folder-container';
import { MessageContainer } from '@colanode/ui/components/messages/message-container';
@@ -13,14 +15,32 @@ import { PageContainer } from '@colanode/ui/components/pages/page-container';
import { RecordContainer } from '@colanode/ui/components/records/record-container';
import { SpaceContainer } from '@colanode/ui/components/spaces/space-container';
import { TabsContent } from '@colanode/ui/components/ui/tabs';
import { WorkspaceSettings } from '@colanode/ui/components/workspaces/workspace-settings';
import { WorkspaceUsers } from '@colanode/ui/components/workspaces/workspace-users';
interface ContainerTabContentProps {
tab: ContainerTab;
}
const ContainerTabContentBody = ({ tab }: ContainerTabContentProps) => {
if (tab.path === 'downloads') {
return <DownloadsList />;
if (tab.path === SpecialContainerTabPath.Downloads) {
return <DownloadsContainer />;
}
if (tab.path === SpecialContainerTabPath.WorkspaceSettings) {
return <WorkspaceSettings />;
}
if (tab.path === SpecialContainerTabPath.WorkspaceUsers) {
return <WorkspaceUsers />;
}
if (tab.path === SpecialContainerTabPath.AccountSettings) {
return <AccountSettings />;
}
if (tab.path === SpecialContainerTabPath.AccountLogout) {
return <AccountLogout />;
}
return match(getIdType(tab.path))

View File

@@ -3,8 +3,10 @@ import { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { match } from 'ts-pattern';
import { ContainerTab } from '@colanode/client/types';
import { ContainerTab, SpecialContainerTabPath } from '@colanode/client/types';
import { getIdType, IdType } from '@colanode/core';
import { AccountLogoutTab } from '@colanode/ui/components/accounts/account-logout-tab';
import { AccountSettingsTab } from '@colanode/ui/components/accounts/account-settings-tab';
import { ChannelContainerTab } from '@colanode/ui/components/channels/channel-container-tab';
import { ChatContainerTab } from '@colanode/ui/components/chats/chat-container-tab';
import { DatabaseContainerTab } from '@colanode/ui/components/databases/database-container-tab';
@@ -16,6 +18,8 @@ import { PageContainerTab } from '@colanode/ui/components/pages/page-container-t
import { RecordContainerTab } from '@colanode/ui/components/records/record-container-tab';
import { SpaceContainerTab } from '@colanode/ui/components/spaces/space-container-tab';
import { TabsTrigger } from '@colanode/ui/components/ui/tabs';
import { WorkspaceSettingsTab } from '@colanode/ui/components/workspaces/workspace-settings-tab';
import { WorkspaceUsersTab } from '@colanode/ui/components/workspaces/workspace-users-tab';
import { cn } from '@colanode/ui/lib/utils';
interface ContainerTabTriggerProps {
@@ -26,10 +30,26 @@ interface ContainerTabTriggerProps {
}
const ContainerTabTriggerContent = ({ tab }: { tab: ContainerTab }) => {
if (tab.path === 'downloads') {
if (tab.path === SpecialContainerTabPath.Downloads) {
return <DownloadsContainerTab />;
}
if (tab.path === SpecialContainerTabPath.WorkspaceSettings) {
return <WorkspaceSettingsTab />;
}
if (tab.path === SpecialContainerTabPath.WorkspaceUsers) {
return <WorkspaceUsersTab />;
}
if (tab.path === SpecialContainerTabPath.AccountSettings) {
return <AccountSettingsTab />;
}
if (tab.path === SpecialContainerTabPath.AccountLogout) {
return <AccountLogoutTab />;
}
return match(getIdType(tab.path))
.with(IdType.Space, () => <SpaceContainerTab spaceId={tab.path} />)
.with(IdType.Channel, () => (

View File

@@ -1,5 +1,6 @@
import { ChatCreatePopover } from '@colanode/ui/components/chats/chat-create-popover';
import { ChatSidebarItem } from '@colanode/ui/components/chats/chat-sidebar-item';
import { SidebarHeader } from '@colanode/ui/components/layouts/sidebars/sidebar-header';
import { useLayout } from '@colanode/ui/contexts/layout';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
@@ -21,14 +22,7 @@ export const SidebarChats = () => {
return (
<div className="flex flex-col group/sidebar-chats h-full px-2">
<div className="flex items-center justify-between h-12 pl-2 pr-1 app-drag-region">
<p className="font-bold text-muted-foreground flex-grow app-no-drag-region">
Chats
</p>
<div className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar-chats:opacity-100 flex items-center justify-center app-no-drag-region">
<ChatCreatePopover />
</div>
</div>
<SidebarHeader title="Chats" actions={<ChatCreatePopover />} />
<div className="flex w-full min-w-0 flex-col gap-1">
{chats.map((item) => (
<button

View File

@@ -0,0 +1,19 @@
interface SidebarHeaderProps {
title: string;
actions?: React.ReactNode;
}
export const SidebarHeader = ({ title, actions }: SidebarHeaderProps) => {
return (
<div className="flex items-center justify-between h-12 pl-2 pr-1 app-drag-region">
<p className="font-bold text-muted-foreground flex-grow app-no-drag-region">
{title}
</p>
{actions && (
<div className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar-chats:opacity-100 flex items-center justify-center app-no-drag-region">
{actions}
</div>
)}
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { Check, LogOut, Plus, Settings } from 'lucide-react';
import { Fragment, useState } from 'react';
import { Check, Plus } from 'lucide-react';
import { useState } from 'react';
import { UnreadState } from '@colanode/client/types';
import { Avatar } from '@colanode/ui/components/avatars/avatar';
@@ -66,96 +66,56 @@ export function SidebarMenuFooter() {
align="end"
sideOffset={4}
>
<DropdownMenuItem key={account.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={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>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-2 p-2 cursor-pointer"
onClick={() => {
account.openSettings();
}}
>
<Settings className="size-4" />
<p className="font-medium">Settings</p>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 p-2 cursor-pointer"
onClick={() => {
account.openLogout();
}}
>
<LogOut className="size-4" />
<p className="font-medium">Logout</p>
</DropdownMenuItem>
{otherAccounts.length > 0 && (
<Fragment>
<DropdownMenuSeparator />
<DropdownMenuLabel className="mb-1">Accounts</DropdownMenuLabel>
{accounts.map((accountItem) => {
const state = accountUnreadStates[accountItem.id] ?? {
unreadCount: 0,
hasUnread: false,
};
<DropdownMenuLabel className="mb-1">Accounts</DropdownMenuLabel>
{accounts.map((accountItem) => {
const state = accountUnreadStates[accountItem.id] ?? {
unreadCount: 0,
hasUnread: false,
};
return (
<DropdownMenuItem
key={accountItem.id}
className="p-0"
onClick={() => {
app.openAccount(accountItem.id);
}}
>
<AccountContext.Provider
value={{
...accountItem,
openSettings: () => {},
openLogout: () => {},
openWorkspace: () => {},
openWorkspaceCreate: () => {},
}}
>
<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={accountItem.id}
name={accountItem.name}
avatar={accountItem.avatar}
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{accountItem.name}
</span>
<span className="truncate text-xs">
{accountItem.email}
</span>
</div>
{accountItem.id === account.id ? (
<Check className="size-4" />
) : (
<UnreadBadge
count={state.unreadCount}
unread={state.hasUnread}
/>
)}
</div>
</AccountContext.Provider>
</DropdownMenuItem>
);
})}
</Fragment>
)}
return (
<DropdownMenuItem
key={accountItem.id}
className="p-0"
onClick={() => {
app.openAccount(accountItem.id);
}}
>
<AccountContext.Provider
value={{
...accountItem,
openWorkspace: () => {},
openWorkspaceCreate: () => {},
}}
>
<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={accountItem.id}
name={accountItem.name}
avatar={accountItem.avatar}
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{accountItem.name}
</span>
<span className="truncate text-xs">
{accountItem.email}
</span>
</div>
{accountItem.id === account.id ? (
<Check className="size-4" />
) : (
<UnreadBadge
count={state.unreadCount}
unread={state.hasUnread}
/>
)}
</div>
</AccountContext.Provider>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem

View File

@@ -1,5 +1,5 @@
import { Bell, Check, Plus, Settings } from 'lucide-react';
import { Fragment, useState } from 'react';
import { Check, Plus } from 'lucide-react';
import { useState } from 'react';
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import {
@@ -61,74 +61,42 @@ export const SidebarMenuHeader = () => {
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 cursor-pointer"
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 && (
<Fragment>
<DropdownMenuSeparator />
<DropdownMenuLabel className="mb-1">Workspaces</DropdownMenuLabel>
{workspaces.map((workspaceItem) => {
const workspaceUnreadState = radar.getWorkspaceState(
workspaceItem.accountId,
workspaceItem.id
);
return (
<DropdownMenuItem
key={workspaceItem.id}
className="p-0 cursor-pointer"
onClick={() => {
account.openWorkspace(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" />
) : (
<UnreadBadge
count={workspaceUnreadState.state.unreadCount}
unread={workspaceUnreadState.state.hasUnread}
/>
)}
</div>
</DropdownMenuItem>
);
})}
</Fragment>
)}
<DropdownMenuLabel className="mb-1">Workspaces</DropdownMenuLabel>
{workspaces.map((workspaceItem) => {
const workspaceUnreadState = radar.getWorkspaceState(
workspaceItem.accountId,
workspaceItem.id
);
return (
<DropdownMenuItem
key={workspaceItem.id}
className="p-0 cursor-pointer"
onClick={() => {
account.openWorkspace(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" />
) : (
<UnreadBadge
count={workspaceUnreadState.state.unreadCount}
unread={workspaceUnreadState.state.hasUnread}
/>
)}
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
className="gap-2 p-2 text-muted-foreground hover:text-foreground cursor-pointer"

View File

@@ -7,6 +7,7 @@ interface SidebarMenuIconProps {
onClick: () => void;
isActive?: boolean;
unreadState?: UnreadState;
className?: string;
}
export const SidebarMenuIcon = ({
@@ -14,11 +15,13 @@ export const SidebarMenuIcon = ({
onClick,
isActive = false,
unreadState,
className,
}: SidebarMenuIconProps) => {
return (
<div
className={cn(
'w-10 h-10 flex items-center justify-center cursor-pointer hover:bg-gray-200 rounded-md relative',
className,
isActive ? 'bg-gray-200' : ''
)}
onClick={onClick}

View File

@@ -1,4 +1,4 @@
import { LayoutGrid, MessageCircle } from 'lucide-react';
import { LayoutGrid, MessageCircle, Settings } from 'lucide-react';
import { SidebarMenuType } from '@colanode/client/types';
import { SidebarMenuFooter } from '@colanode/ui/components/layouts/sidebars/sidebar-menu-footer';
@@ -57,6 +57,14 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
isActive={value === 'spaces'}
unreadState={channelsState}
/>
<SidebarMenuIcon
icon={Settings}
onClick={() => {
onChange('settings');
}}
isActive={value === 'settings'}
className="mt-auto"
/>
</div>
<SidebarMenuFooter />
</div>

View File

@@ -0,0 +1,32 @@
import { useLayout } from '@colanode/ui/contexts/layout';
import { cn } from '@colanode/ui/lib/utils';
interface SidebarSettingsItemProps {
title: string;
path: string;
icon: React.FC<React.SVGProps<SVGSVGElement>>;
}
export const SidebarSettingsItem = ({
title,
icon: Icon,
path,
}: SidebarSettingsItemProps) => {
const layout = useLayout();
const isActive = layout.activeTab === path;
return (
<div
className={cn(
'text-sm flex h-7 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground cursor-pointer',
isActive && 'font-semibold'
)}
onClick={() => {
layout.previewLeft(path);
}}
>
<Icon className="size-4" />
<span className="line-clamp-1 w-full flex-grow text-left">{title}</span>
</div>
);
};

View File

@@ -0,0 +1,42 @@
import { LogOut, Settings, Users } from 'lucide-react';
import { SpecialContainerTabPath } from '@colanode/client/types';
import { SidebarHeader } from '@colanode/ui/components/layouts/sidebars/sidebar-header';
import { SidebarSettingsItem } from '@colanode/ui/components/layouts/sidebars/sidebar-settings-item';
import { Separator } from '@colanode/ui/components/ui/separator';
export const SidebarSettings = () => {
return (
<div className="flex flex-col gap-4 h-full px-2">
<div className="flex w-full min-w-0 flex-col gap-1">
<SidebarHeader title="Workspace settings" />
<SidebarSettingsItem
title="General"
icon={Settings}
path={SpecialContainerTabPath.WorkspaceSettings}
/>
<SidebarSettingsItem
title="Users"
icon={Users}
path={SpecialContainerTabPath.WorkspaceUsers}
/>
</div>
<div className="flex w-full min-w-0 flex-col gap-1">
<SidebarHeader title="Account settings" />
<SidebarSettingsItem
title="General"
icon={Settings}
path={SpecialContainerTabPath.AccountSettings}
/>
</div>
<div className="flex w-full min-w-0 flex-col gap-1">
<Separator className="my-2" />
<SidebarSettingsItem
title="Logout"
icon={LogOut}
path={SpecialContainerTabPath.AccountLogout}
/>
</div>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { SidebarHeader } from '@colanode/ui/components/layouts/sidebars/sidebar-header';
import { SpaceCreateButton } from '@colanode/ui/components/spaces/space-create-button';
import { SpaceSidebarItem } from '@colanode/ui/components/spaces/space-sidebar-item';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
@@ -21,16 +22,10 @@ export const SidebarSpaces = () => {
return (
<div className="flex flex-col group/sidebar-spaces h-full px-2">
<div className="flex items-center justify-between h-12 pl-2 pr-1 app-drag-region">
<p className="font-bold text-muted-foreground flex-grow app-no-drag-region">
Spaces
</p>
{canCreateSpace && (
<div className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar-spaces:opacity-100 flex items-center justify-center app-no-drag-region">
<SpaceCreateButton />
</div>
)}
</div>
<SidebarHeader
title="Spaces"
actions={canCreateSpace && <SpaceCreateButton />}
/>
<div className="flex w-full min-w-0 flex-col gap-1">
{spaces.map((space) => (
<SpaceSidebarItem space={space} key={space.id} />

View File

@@ -1,6 +1,7 @@
import { SidebarMenuType } from '@colanode/client/types';
import { SidebarChats } from '@colanode/ui/components/layouts/sidebars/sidebar-chats';
import { SidebarMenu } from '@colanode/ui/components/layouts/sidebars/sidebar-menu';
import { SidebarSettings } from '@colanode/ui/components/layouts/sidebars/sidebar-settings';
import { SidebarSpaces } from '@colanode/ui/components/layouts/sidebars/sidebar-spaces';
interface SidebarProps {
@@ -15,6 +16,7 @@ export const Sidebar = ({ menu, onMenuChange }: SidebarProps) => {
<div className="min-h-0 flex-grow overflow-auto">
{menu === 'spaces' && <SidebarSpaces />}
{menu === 'chats' && <SidebarChats />}
{menu === 'settings' && <SidebarSettings />}
</div>
</div>
);

View File

@@ -15,35 +15,33 @@ import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
interface WorkspaceDeleteProps {
onDeleted: () => void;
}
export const WorkspaceDelete = ({ onDeleted }: WorkspaceDeleteProps) => {
export const WorkspaceDelete = () => {
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
const [showDeleteModal, setShowDeleteModal] = useState(false);
return (
<div className="flex flex-col gap-4">
<h3 className="font-heading mb-px text-2xl font-semibold tracking-tight">
Delete workspace
</h3>
<p>Deleting a workspace is permanent and cannot be undone.</p>
<p>
All data associated with the workspace will be deleted, including users,
chats, messages, pages, channels, databases, records, files and more.
</p>
<div>
<Button
variant="destructive"
onClick={() => {
setShowDeleteModal(true);
}}
>
Delete
</Button>
<>
<div className="flex items-center justify-between gap-6">
<div className="flex-1 space-y-2">
<h3 className="font-semibold">Delete workspace</h3>
<p className="text-sm text-muted-foreground">
Once you delete a workspace, there is no going back. Please be
certain.
</p>
</div>
<div className="flex-shrink-0">
<Button
variant="destructive"
onClick={() => {
setShowDeleteModal(true);
}}
className="w-20"
>
Delete
</Button>
</div>
</div>
<AlertDialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<AlertDialogContent>
@@ -70,7 +68,6 @@ export const WorkspaceDelete = ({ onDeleted }: WorkspaceDeleteProps) => {
},
onSuccess() {
setShowDeleteModal(false);
onDeleted();
toast.success('Workspace was deleted successfully');
},
onError(error) {
@@ -85,6 +82,6 @@ export const WorkspaceDelete = ({ onDeleted }: WorkspaceDeleteProps) => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
};

View File

@@ -175,7 +175,11 @@ export const WorkspaceForm = ({
</Button>
)}
<Button type="submit" disabled={isPending || isSaving}>
<Button
type="submit"
disabled={isPending || isSaving}
className="w-20"
>
{isSaving && <Spinner className="mr-1" />}
{saveText}
</Button>

View File

@@ -0,0 +1,29 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
} from '@colanode/ui/components/ui/breadcrumb';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
export const WorkspaceSettingsBreadcrumb = () => {
const workspace = useWorkspace();
return (
<Breadcrumb className="flex-grow">
<BreadcrumbList>
<BreadcrumbItem className="cursor-pointer hover:text-foreground">
<div className="flex items-center space-x-2">
<Avatar
id={workspace.id}
name={workspace.name}
avatar={workspace.avatar}
className="size-4"
/>
<span>{workspace.name} Settings</span>
</div>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -1,119 +0,0 @@
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Info, Trash2, Users } from 'lucide-react';
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@colanode/ui/components/ui/dialog';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@colanode/ui/components/ui/tabs';
import { WorkspaceDelete } from '@colanode/ui/components/workspaces/workspace-delete';
import { WorkspaceUpdate } from '@colanode/ui/components/workspaces/workspace-update';
import { WorkspaceUsers } from '@colanode/ui/components/workspaces/workspace-users';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
interface WorkspaceSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const WorkspaceSettingsDialog = ({
open,
onOpenChange,
}: WorkspaceSettingsDialogProps) => {
const workspace = useWorkspace();
if (!workspace) {
return null;
}
const canDelete = workspace.role === 'owner';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="md:min-h-3/4 md:max-h-3/4 p-3 md:h-3/4 md:w-3/4 md:max-w-full"
aria-describedby={undefined}
>
<VisuallyHidden>
<DialogTitle>Workspace Settings</DialogTitle>
</VisuallyHidden>
<Tabs
defaultValue="info"
className="grid h-full max-h-full grid-cols-[240px_minmax(0,1fr)] overflow-hidden"
>
<TabsList className="flex w-full max-h-full flex-col items-start justify-start gap-1 rounded-none border-r border-r-gray-100 bg-white pr-3">
<div className="mb-1 flex h-10 w-full items-center justify-between bg-gray-50 p-1 text-foreground/80">
<div className="flex items-center gap-2">
<Avatar
id={workspace.id}
name={workspace.name}
avatar={workspace.avatar}
size="small"
/>
<span className="truncate font-semibold">{workspace.name}</span>
</div>
</div>
<TabsTrigger
key="tab-trigger-info"
className="w-full justify-start p-2 hover:bg-gray-50 cursor-pointer"
value="info"
>
<Info className="mr-2 size-4" />
Info
</TabsTrigger>
<TabsTrigger
key="tab-trigger-users"
className="w-full justify-start p-2 hover:bg-gray-50 cursor-pointer"
value="users"
>
<Users className="mr-2 size-4" />
Users
</TabsTrigger>
{canDelete && (
<TabsTrigger
key="tab-trigger-delete"
className="w-full justify-start p-2 hover:bg-gray-50 cursor-pointer"
value="delete"
>
<Trash2 className="mr-2 size-4" />
Delete
</TabsTrigger>
)}
</TabsList>
<div className="overflow-auto p-4">
<TabsContent
key="tab-content-info"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="info"
>
<WorkspaceUpdate />
</TabsContent>
<TabsContent
key="tab-content-users"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="users"
>
<WorkspaceUsers />
</TabsContent>
{canDelete && (
<TabsContent
key="tab-content-delete"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="delete"
>
<WorkspaceDelete onDeleted={() => onOpenChange(false)} />
</TabsContent>
)}
</div>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,18 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
export const WorkspaceSettingsTab = () => {
const workspace = useWorkspace();
return (
<div className="flex items-center space-x-2">
<Avatar
id={workspace.id}
name={workspace.name}
avatar={workspace.avatar}
size="small"
/>
<span>{workspace.name} Settings</span>
</div>
);
};

View File

@@ -0,0 +1,75 @@
import { toast } from 'sonner';
import {
Container,
ContainerBody,
ContainerHeader,
} from '@colanode/ui/components/ui/container';
import { Separator } from '@colanode/ui/components/ui/separator';
import { WorkspaceDelete } from '@colanode/ui/components/workspaces/workspace-delete';
import { WorkspaceForm } from '@colanode/ui/components/workspaces/workspace-form';
import { WorkspaceSettingsBreadcrumb } from '@colanode/ui/components/workspaces/workspace-settings-breadcrumb';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
export const WorkspaceSettings = () => {
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
const canEdit = workspace.role === 'owner';
return (
<Container>
<ContainerHeader>
<WorkspaceSettingsBreadcrumb />
</ContainerHeader>
<ContainerBody className="max-w-4xl">
<div className="space-y-8">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">General</h2>
<Separator className="mt-3" />
</div>
<WorkspaceForm
readOnly={!canEdit}
values={{
name: workspace.name,
description: workspace.description ?? '',
avatar: workspace.avatar ?? null,
}}
onSubmit={(values) => {
mutate({
input: {
type: 'workspace.update',
id: workspace.id,
accountId: workspace.accountId,
name: values.name,
description: values.description,
avatar: values.avatar ?? null,
},
onSuccess() {
toast.success('Workspace updated');
},
onError(error) {
toast.error(error.message);
},
});
}}
isSaving={isPending}
saveText="Update"
/>
</div>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Danger Zone
</h2>
<Separator className="mt-3" />
</div>
<WorkspaceDelete />
</div>
</div>
</ContainerBody>
</Container>
);
};

View File

@@ -1,42 +0,0 @@
import { toast } from 'sonner';
import { WorkspaceForm } from '@colanode/ui/components/workspaces/workspace-form';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
export const WorkspaceUpdate = () => {
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
const canEdit = workspace.role === 'owner';
return (
<WorkspaceForm
readOnly={!canEdit}
values={{
name: workspace.name,
description: workspace.description ?? '',
avatar: workspace.avatar ?? null,
}}
onSubmit={(values) => {
mutate({
input: {
type: 'workspace.update',
id: workspace.id,
accountId: workspace.accountId,
name: values.name,
description: values.description,
avatar: values.avatar ?? null,
},
onSuccess() {
toast.success('Workspace updated');
},
onError(error) {
toast.error(error.message);
},
});
}}
isSaving={isPending}
saveText="Update"
/>
);
};

View File

@@ -23,14 +23,9 @@ export const WorkspaceUserInvite = ({
return (
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center gap-2">
<div>
<p>Invite with email</p>
<p className="text-sm text-muted-foreground">
Write the email addresses of the people you want to invite
</p>
</div>
</div>
<p className="text-sm text-muted-foreground">
Write the email addresses of the people you want to invite
</p>
<div className="flex flex-row items-center gap-1">
<div className="flex h-9 w-full flex-row gap-2 rounded-md border border-input bg-background p-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground">
{emails.map((email) => (

View File

@@ -0,0 +1,29 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
} from '@colanode/ui/components/ui/breadcrumb';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
export const WorkspaceUsersBreadcrumb = () => {
const workspace = useWorkspace();
return (
<Breadcrumb className="flex-grow">
<BreadcrumbList>
<BreadcrumbItem className="cursor-pointer hover:text-foreground">
<div className="flex items-center space-x-2">
<Avatar
id={workspace.id}
name={workspace.name}
avatar={workspace.avatar}
className="size-4"
/>
<span>{workspace.name} Users</span>
</div>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -0,0 +1,18 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
export const WorkspaceUsersTab = () => {
const workspace = useWorkspace();
return (
<div className="flex items-center space-x-2">
<Avatar
id={workspace.id}
name={workspace.name}
avatar={workspace.avatar}
size="small"
/>
<span>{workspace.name} Users</span>
</div>
);
};

View File

@@ -1,13 +1,19 @@
import { Fragment, useState } from 'react';
import { useState } from 'react';
import { InView } from 'react-intersection-observer';
import { UserListQueryInput } from '@colanode/client/queries';
import { WorkspaceRole } from '@colanode/core';
import { Avatar } from '@colanode/ui/components/avatars/avatar';
import {
Container,
ContainerBody,
ContainerHeader,
} from '@colanode/ui/components/ui/container';
import { Separator } from '@colanode/ui/components/ui/separator';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { WorkspaceUserInvite } from '@colanode/ui/components/workspaces/workspace-user-invite';
import { WorkspaceUserRoleDropdown } from '@colanode/ui/components/workspaces/workspace-user-role-dropdown';
import { WorkspaceUsersBreadcrumb } from '@colanode/ui/components/workspaces/workspace-users-breadcrumb';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQueries } from '@colanode/ui/hooks/use-queries';
@@ -34,57 +40,73 @@ export const WorkspaceUsers = () => {
const hasMore = !isPending && users.length === lastPage * USERS_PER_PAGE;
return (
<div className="flex flex-col space-y-4">
{canEditUsers && (
<Fragment>
<WorkspaceUserInvite workspace={workspace} />
<Separator />
</Fragment>
)}
<div>
<p>Users</p>
<p className="text-sm text-muted-foreground">
The list of all users on the workspaces
</p>
</div>
<div className="flex flex-col gap-2">
{users.map((user) => {
const name: string = user.name ?? 'Unknown';
const email: string = user.email ?? ' ';
const avatar: string | null | undefined = user.avatar;
const role: WorkspaceRole = user.role;
if (!role) {
return null;
}
return (
<div key={user.id} className="flex items-center space-x-3">
<Avatar id={user.id} name={name} avatar={avatar} />
<div className="flex-grow">
<p className="text-sm font-medium leading-none">{name}</p>
<p className="text-sm text-muted-foreground">{email}</p>
<Container>
<ContainerHeader>
<WorkspaceUsersBreadcrumb />
</ContainerHeader>
<ContainerBody className="max-w-4xl">
<div className="space-y-8">
{canEditUsers && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Invite
</h2>
<Separator className="mt-3" />
</div>
<WorkspaceUserRoleDropdown
userId={user.id}
value={role}
canEdit={canEditUsers}
/>
<WorkspaceUserInvite workspace={workspace} />
</div>
);
})}
<div className="flex items-center justify-center space-x-3">
{isPending && <Spinner />}
)}
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Users</h2>
<p className="text-sm text-muted-foreground mt-1">
The list of all users on the workspace
</p>
<Separator className="mt-3" />
</div>
<div className="flex flex-col gap-3">
{users.map((user) => {
const name: string = user.name ?? 'Unknown';
const email: string = user.email ?? ' ';
const avatar: string | null | undefined = user.avatar;
const role: WorkspaceRole = user.role;
if (!role) {
return null;
}
return (
<div key={user.id} className="flex items-center space-x-3">
<Avatar id={user.id} name={name} avatar={avatar} />
<div className="flex-grow">
<p className="text-sm font-medium leading-none">{name}</p>
<p className="text-sm text-muted-foreground">{email}</p>
</div>
<WorkspaceUserRoleDropdown
userId={user.id}
value={role}
canEdit={canEditUsers}
/>
</div>
);
})}
<div className="flex items-center justify-center space-x-3">
{isPending && <Spinner />}
</div>
<InView
rootMargin="200px"
onChange={(inView) => {
if (inView && hasMore && !isPending) {
setLastPage(lastPage + 1);
}
}}
></InView>
</div>
</div>
</div>
<InView
rootMargin="200px"
onChange={(inView) => {
if (inView && hasMore && !isPending) {
setLastPage(lastPage + 1);
}
}}
></InView>
</div>
</div>
</ContainerBody>
</Container>
);
};

View File

@@ -1,12 +1,9 @@
import { useState } from 'react';
import {
WorkspaceMetadataKey,
WorkspaceMetadataMap,
Workspace as WorkspaceType,
} from '@colanode/client/types';
import { Layout } from '@colanode/ui/components/layouts/layout';
import { WorkspaceSettingsDialog } from '@colanode/ui/components/workspaces/workspace-settings-dialog';
import { useAccount } from '@colanode/ui/contexts/account';
import { WorkspaceContext } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
@@ -17,7 +14,6 @@ interface WorkspaceProps {
export const Workspace = ({ workspace }: WorkspaceProps) => {
const account = useAccount();
const [openSettings, setOpenSettings] = useState(false);
const workspaceMetadataListQuery = useQuery({
type: 'workspace.metadata.list',
@@ -33,9 +29,6 @@ export const Workspace = ({ workspace }: WorkspaceProps) => {
<WorkspaceContext.Provider
value={{
...workspace,
openSettings() {
setOpenSettings(true);
},
getMetadata<K extends WorkspaceMetadataKey>(key: K) {
const value = workspaceMetadataListQuery.data?.find(
(m) => m.key === key
@@ -73,12 +66,6 @@ export const Workspace = ({ workspace }: WorkspaceProps) => {
}}
>
<Layout key={workspace.id} />
{openSettings && (
<WorkspaceSettingsDialog
open={openSettings}
onOpenChange={() => setOpenSettings(false)}
/>
)}
</WorkspaceContext.Provider>
);
};

View File

@@ -3,8 +3,6 @@ import { createContext, useContext } from 'react';
import { Account } from '@colanode/client/types';
interface AccountContext extends Account {
openSettings: () => void;
openLogout: () => void;
openWorkspace: (id: string) => void;
openWorkspaceCreate: () => void;
}

View File

@@ -7,7 +7,6 @@ import {
} from '@colanode/client/types';
interface WorkspaceContext extends Workspace {
openSettings: () => void;
getMetadata: <K extends WorkspaceMetadataKey>(
key: K
) => WorkspaceMetadataMap[K] | undefined;