Refactor sidebar components

This commit is contained in:
Hakan Shehu
2024-11-12 10:41:00 +01:00
parent fcb6165393
commit c25678a318
20 changed files with 436 additions and 627 deletions

View File

@@ -4,8 +4,6 @@ import { AccountListQueryHandler } from '@/main/handlers/queries/accounts-list';
import { MessageListQueryHandler } from '@/main/handlers/queries/message-list';
import { NodeGetQueryHandler } from '@/main/handlers/queries/node-get';
import { ServerListQueryHandler } from '@/main/handlers/queries/server-list';
import { SidebarChatListQueryHandler } from '@/main/handlers/queries/sidebar-chat-list';
import { SidebarSpaceListQueryHandler } from '@/main/handlers/queries/sidebar-space-list';
import { UserSearchQueryHandler } from '@/main/handlers/queries/user-search';
import { WorkspaceListQueryHandler } from '@/main/handlers/queries/workspace-list';
import { WorkspaceUserListQueryHandler } from '@/main/handlers/queries/workspace-user-list';
@@ -14,6 +12,7 @@ import { FileListQueryHandler } from '@/main/handlers/queries/file-list';
import { EmojisGetQueryHandler } from '@/main/handlers/queries/emojis-get';
import { IconsGetQueryHandler } from '@/main/handlers/queries/icons-get';
import { NodeWithAncestorsGetQueryHandler } from '@/main/handlers/queries/node-with-ancestors-get';
import { NodeChildrenGetQueryHandler } from '@/main/handlers/queries/node-children-get';
type QueryHandlerMap = {
[K in keyof QueryMap]: QueryHandler<QueryMap[K]['input']>;
@@ -25,8 +24,6 @@ export const queryHandlerMap: QueryHandlerMap = {
node_get: new NodeGetQueryHandler(),
record_list: new RecordListQueryHandler(),
server_list: new ServerListQueryHandler(),
sidebar_chat_list: new SidebarChatListQueryHandler(),
sidebar_space_list: new SidebarSpaceListQueryHandler(),
user_search: new UserSearchQueryHandler(),
workspace_list: new WorkspaceListQueryHandler(),
workspace_user_list: new WorkspaceUserListQueryHandler(),
@@ -34,4 +31,5 @@ export const queryHandlerMap: QueryHandlerMap = {
emojis_get: new EmojisGetQueryHandler(),
icons_get: new IconsGetQueryHandler(),
node_with_ancestors_get: new NodeWithAncestorsGetQueryHandler(),
node_children_get: new NodeChildrenGetQueryHandler(),
};

View File

@@ -0,0 +1,81 @@
import { NodeChildrenGetQueryInput } from '@/operations/queries/node-children-get';
import { databaseManager } from '@/main/data/database-manager';
import { mapNode } from '@/main/utils';
import { SelectNode } from '@/main/data/workspace/schema';
import {
MutationChange,
ChangeCheckResult,
QueryHandler,
QueryResult,
} from '@/main/types';
import { isEqual } from 'lodash-es';
export class NodeChildrenGetQueryHandler
implements QueryHandler<NodeChildrenGetQueryInput>
{
public async handleQuery(
input: NodeChildrenGetQueryInput
): Promise<QueryResult<NodeChildrenGetQueryInput>> {
const rows = await this.fetchChildren(input);
return {
output: rows.map(mapNode),
state: {
rows,
},
};
}
public async checkForChanges(
changes: MutationChange[],
input: NodeChildrenGetQueryInput,
state: Record<string, any>
): Promise<ChangeCheckResult<NodeChildrenGetQueryInput>> {
if (
!changes.some(
(change) =>
change.type === 'workspace' &&
change.table === 'nodes' &&
change.userId === input.userId
)
) {
return {
hasChanges: false,
};
}
const rows = await this.fetchChildren(input);
if (isEqual(rows, state.rows)) {
return {
hasChanges: false,
};
}
return {
hasChanges: true,
result: {
output: rows.map(mapNode),
state: {
rows,
},
},
};
}
private async fetchChildren(
input: NodeChildrenGetQueryInput
): Promise<SelectNode[]> {
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
input.userId
);
const rows = await workspaceDatabase
.selectFrom('nodes')
.selectAll()
.where('parent_id', '=', input.nodeId)
.where('type', 'in', input.types ?? [])
.execute();
return rows;
}
}

View File

@@ -1,233 +0,0 @@
import { SidebarChatListQueryInput } from '@/operations/queries/sidebar-chat-list';
import { databaseManager } from '@/main/data/database-manager';
import { SelectNode } from '@/main/data/workspace/schema';
import { NodeTypes } from '@colanode/core';
import { SidebarChatNode } from '@/types/workspaces';
import { mapNode } from '@/main/utils';
import {
MutationChange,
ChangeCheckResult,
QueryHandler,
QueryResult,
} from '@/main/types';
import { isEqual } from 'lodash-es';
interface UnreadCountRow {
node_id: string;
unread_count: number;
mentions_count: number;
}
export class SidebarChatListQueryHandler
implements QueryHandler<SidebarChatListQueryInput>
{
public async handleQuery(
input: SidebarChatListQueryInput
): Promise<QueryResult<SidebarChatListQueryInput>> {
const chats = await this.fetchChats(input);
const collaborators = await this.fetchChatCollaborators(input, chats);
const unreadCounts = await this.fetchUnreadCounts(input, chats);
return {
output: this.buildSidebarChatNodes(
input.userId,
chats,
collaborators,
unreadCounts
),
state: {
chats,
collaborators,
unreadCounts,
},
};
}
public async checkForChanges(
changes: MutationChange[],
input: SidebarChatListQueryInput,
state: Record<string, any>
): Promise<ChangeCheckResult<SidebarChatListQueryInput>> {
if (
!changes.some(
(change) =>
change.type === 'workspace' &&
(change.table === 'nodes' || change.table === 'user_nodes') &&
change.userId === input.userId
)
) {
return {
hasChanges: false,
};
}
const chats = await this.fetchChats(input);
const collaborators = await this.fetchChatCollaborators(input, chats);
const unreadCounts = await this.fetchUnreadCounts(input, chats);
if (
isEqual(chats, state.chats) &&
isEqual(collaborators, state.collaborators) &&
isEqual(unreadCounts, state.unreadCounts)
) {
return {
hasChanges: false,
};
}
return {
hasChanges: true,
result: {
output: this.buildSidebarChatNodes(
input.userId,
chats,
collaborators,
unreadCounts
),
state: {
chats,
collaborators,
unreadCounts,
},
},
};
}
private async fetchChats(
input: SidebarChatListQueryInput
): Promise<SelectNode[]> {
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
input.userId
);
const chats = await workspaceDatabase
.selectFrom('nodes')
.where('type', '=', NodeTypes.Chat)
.selectAll()
.execute();
return chats;
}
private async fetchChatCollaborators(
input: SidebarChatListQueryInput,
chats: SelectNode[]
): Promise<SelectNode[]> {
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
input.userId
);
const collaboratorIds: string[] = [];
for (const chat of chats) {
if (!chat.attributes) {
continue;
}
const attributes = JSON.parse(chat.attributes);
if (!attributes.collaborators) {
continue;
}
const keys = Object.keys(attributes.collaborators);
for (const key of keys) {
if (key === input.userId) {
continue;
}
collaboratorIds.push(key);
}
}
const collaborators = await workspaceDatabase
.selectFrom('nodes')
.where('id', 'in', collaboratorIds)
.selectAll()
.execute();
return collaborators;
}
private async fetchUnreadCounts(
input: SidebarChatListQueryInput,
chats: SelectNode[]
): Promise<UnreadCountRow[]> {
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
input.userId
);
const chatIds = chats.map((chat) => chat.id);
const unreadCounts = await workspaceDatabase
.selectFrom('user_nodes as un')
.innerJoin('nodes as n', 'un.node_id', 'n.id')
.where('un.user_id', '=', input.userId)
.where('n.type', '=', NodeTypes.Message)
.where('n.parent_id', 'in', chatIds)
.where('un.last_seen_version_id', 'is', null)
.select(['n.parent_id as node_id'])
.select((eb) => [
eb.fn.count<number>('un.node_id').as('unread_count'),
eb.fn.sum<number>('un.mentions_count').as('mentions_count'),
])
.groupBy('n.parent_id')
.execute();
return unreadCounts;
}
private buildSidebarChatNodes = (
userId: string,
chats: SelectNode[],
collaborators: SelectNode[],
unreadCounts: UnreadCountRow[]
): SidebarChatNode[] => {
const sidebarChatNodes: SidebarChatNode[] = [];
for (const chat of chats) {
const chatNode = mapNode(chat);
if (chatNode.type !== 'chat') {
continue;
}
if (!chatNode.attributes || !chatNode.attributes.collaborators) {
continue;
}
const collaboratorIds = Object.keys(chatNode.attributes.collaborators);
if (!collaboratorIds || collaboratorIds.length === 0) {
continue;
}
const collaboratorId = collaboratorIds.find((id) => id !== userId);
if (!collaboratorId) {
continue;
}
const collaboratorRow = collaborators.find(
(r) => r.id === collaboratorId
);
if (!collaboratorRow) {
continue;
}
const collaboratorNode = mapNode(collaboratorRow);
if (collaboratorNode.type !== 'user') {
continue;
}
const unreadCountRow = unreadCounts.find((r) => r.node_id === chat.id);
sidebarChatNodes.push({
id: chatNode.id,
type: chatNode.type,
name: collaboratorNode.attributes.name ?? 'Unknown',
avatar: collaboratorNode.attributes.avatar ?? null,
unreadCount: unreadCountRow?.unread_count ?? 0,
mentionsCount: unreadCountRow?.mentions_count ?? 0,
});
}
return sidebarChatNodes;
};
}

View File

@@ -1,216 +0,0 @@
import { SidebarSpaceListQueryInput } from '@/operations/queries/sidebar-space-list';
import { databaseManager } from '@/main/data/database-manager';
import { sql } from 'kysely';
import { SelectNode } from '@/main/data/workspace/schema';
import { NodeTypes } from '@colanode/core';
import { SidebarNode, SidebarSpaceNode } from '@/types/workspaces';
import { mapNode } from '@/main/utils';
import { Node } from '@colanode/core';
import {
MutationChange,
ChangeCheckResult,
QueryHandler,
QueryResult,
} from '@/main/types';
import { isEqual } from 'lodash-es';
import { compareString } from '@/lib/utils';
interface UnreadCountRow {
node_id: string;
unread_count: number;
mentions_count: number;
}
export class SidebarSpaceListQueryHandler
implements QueryHandler<SidebarSpaceListQueryInput>
{
public async handleQuery(
input: SidebarSpaceListQueryInput
): Promise<QueryResult<SidebarSpaceListQueryInput>> {
const rows = await this.fetchNodes(input);
const unreadCounts = await this.fetchUnreadCounts(input, rows);
return {
output: this.buildSidebarSpaceNodes(rows, unreadCounts),
state: {
rows,
unreadCounts,
},
};
}
public async checkForChanges(
changes: MutationChange[],
input: SidebarSpaceListQueryInput,
state: Record<string, any>
): Promise<ChangeCheckResult<SidebarSpaceListQueryInput>> {
if (
!changes.some(
(change) =>
change.type === 'workspace' &&
(change.table === 'nodes' || change.table === 'user_nodes') &&
change.userId === input.userId
)
) {
return {
hasChanges: false,
};
}
const rows = await this.fetchNodes(input);
const unreadCounts = await this.fetchUnreadCounts(input, rows);
if (
isEqual(rows, state.rows) &&
isEqual(unreadCounts, state.unreadCounts)
) {
return {
hasChanges: false,
};
}
return {
hasChanges: true,
result: {
output: this.buildSidebarSpaceNodes(rows, unreadCounts),
state: {
rows,
},
},
};
}
private async fetchNodes(
input: SidebarSpaceListQueryInput
): Promise<SelectNode[]> {
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
input.userId
);
const query = sql<SelectNode>`
WITH space_nodes AS (
SELECT *
FROM nodes
WHERE type = ${NodeTypes.Space}
),
space_children_nodes AS (
SELECT *
FROM nodes
WHERE parent_id IN (SELECT id FROM space_nodes)
)
SELECT * FROM space_nodes
UNION ALL
SELECT * FROM space_children_nodes;
`.compile(workspaceDatabase);
const result = await workspaceDatabase.executeQuery(query);
return result.rows;
}
private async fetchUnreadCounts(
input: SidebarSpaceListQueryInput,
rows: SelectNode[]
): Promise<UnreadCountRow[]> {
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
input.userId
);
const channelIds = rows
.filter((r) => r.type === NodeTypes.Channel)
.map((r) => r.id);
const unreadCounts = await workspaceDatabase
.selectFrom('user_nodes as un')
.innerJoin('nodes as n', 'un.node_id', 'n.id')
.where('un.user_id', '=', input.userId)
.where('n.type', '=', NodeTypes.Message)
.where('n.parent_id', 'in', channelIds)
.where('un.last_seen_version_id', 'is', null)
.select(['n.parent_id as node_id'])
.select((eb) => [
eb.fn.count<number>('un.node_id').as('unread_count'),
eb.fn.sum<number>('un.mentions_count').as('mentions_count'),
])
.groupBy('n.parent_id')
.execute();
return unreadCounts;
}
private buildSidebarSpaceNodes = (
rows: SelectNode[],
unreadCounts: UnreadCountRow[]
): SidebarSpaceNode[] => {
const nodes: Node[] = rows.map(mapNode);
const spaces: SidebarSpaceNode[] = [];
for (const node of nodes) {
if (node.type !== NodeTypes.Space) {
continue;
}
const children = nodes
.filter((n) => n.parentId === node.id)
.sort((a, b) => compareString(a.index, b.index));
const spaceNode = this.buildSpaceNode(node, children, unreadCounts);
if (spaceNode) {
spaces.push(spaceNode);
}
}
return spaces;
};
private buildSpaceNode = (
node: Node,
children: Node[],
unreadCounts: UnreadCountRow[]
): SidebarSpaceNode | null => {
if (node.type !== 'space') {
return null;
}
const childrenNodes: SidebarNode[] = [];
for (const child of children) {
const childNode = this.buildSidearNode(child, unreadCounts);
if (childNode) {
childrenNodes.push(childNode);
}
}
return {
id: node.id,
type: node.type,
name: node.attributes.name ?? null,
avatar: node.attributes.avatar ?? null,
children: childrenNodes,
unreadCount: 0,
mentionsCount: 0,
};
};
private buildSidearNode = (
node: Node,
unreadCounts: UnreadCountRow[]
): SidebarNode | null => {
const unreadCountRow = unreadCounts.find((r) => r.node_id === node.id);
if (
node.type !== 'channel' &&
node.type !== 'page' &&
node.type !== 'folder' &&
node.type !== 'database'
) {
return null;
}
return {
id: node.id,
type: node.type,
name: node.attributes.name ?? null,
avatar: node.attributes.avatar ?? null,
unreadCount: unreadCountRow?.unread_count ?? 0,
mentionsCount: unreadCountRow?.mentions_count ?? 0,
};
};
}

View File

@@ -0,0 +1,17 @@
import { Node, NodeType } from '@colanode/core';
export type NodeChildrenGetQueryInput = {
type: 'node_children_get';
nodeId: string;
userId: string;
types?: NodeType[];
};
declare module '@/operations/queries' {
interface QueryMap {
node_children_get: {
input: NodeChildrenGetQueryInput;
output: Node[];
};
}
}

View File

@@ -1,15 +0,0 @@
import { SidebarChatNode } from '@/types/workspaces';
export type SidebarChatListQueryInput = {
type: 'sidebar_chat_list';
userId: string;
};
declare module '@/operations/queries' {
interface QueryMap {
sidebar_chat_list: {
input: SidebarChatListQueryInput;
output: SidebarChatNode[];
};
}
}

View File

@@ -1,15 +0,0 @@
import { SidebarSpaceNode } from '@/types/workspaces';
export type SidebarSpaceListQueryInput = {
type: 'sidebar_space_list';
userId: string;
};
declare module '@/operations/queries' {
interface QueryMap {
sidebar_space_list: {
input: SidebarSpaceListQueryInput;
output: SidebarSpaceNode[];
};
}
}

View File

@@ -0,0 +1,48 @@
import { cn } from '@/lib/utils';
import { ChannelNode } from '@colanode/core';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace';
interface ChannelSidebarItemProps {
node: ChannelNode;
}
export const ChannelSidebarItem = ({ node }: ChannelSidebarItemProps) => {
const workspace = useWorkspace();
const isActive = workspace.isNodeActive(node.id);
const isUnread = false;
const mentionsCount = 0;
return (
<button
key={node.id}
className={cn(
'flex w-full items-center',
isActive && 'bg-sidebar-accent'
)}
>
<Avatar
id={node.id}
avatar={node.attributes.avatar}
name={node.attributes.name}
className="h-4 w-4"
/>
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
isUnread && 'font-bold'
)}
>
{node.attributes.name ?? 'Unnamed'}
</span>
{mentionsCount > 0 && (
<span className="mr-1 rounded-md bg-sidebar-accent px-1 py-0.5 text-xs text-sidebar-accent-foreground">
{mentionsCount}
</span>
)}
{mentionsCount == 0 && isUnread && (
<span className="size-2 rounded-full bg-red-500" />
)}
</button>
);
};

View File

@@ -0,0 +1,65 @@
import { cn } from '@/lib/utils';
import { ChatNode } from '@colanode/core';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
interface ChatSidebarItemProps {
node: ChatNode;
}
export const ChatSidebarItem = ({ node }: ChatSidebarItemProps) => {
const workspace = useWorkspace();
const collaboratorId =
Object.keys(node.attributes.collaborators).find(
(id) => id !== workspace.userId
) ?? '';
const { data, isPending } = useQuery({
type: 'node_get',
nodeId: collaboratorId,
userId: workspace.userId,
});
if (isPending || !data || data.type !== 'user') {
return null;
}
const isActive = workspace.isNodeActive(node.id);
const isUnread = false;
const mentionsCount = 0;
return (
<button
key={node.id}
className={cn(
'flex w-full items-center',
isActive && 'bg-sidebar-accent'
)}
>
<Avatar
id={data.id}
avatar={data.attributes.avatar}
name={data.attributes.name}
className="h-5 w-5"
/>
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
isUnread && 'font-bold'
)}
>
{data.attributes.name ?? 'Unnamed'}
</span>
{mentionsCount > 0 && (
<span className="mr-1 rounded-md bg-sidebar-accent px-1 py-0.5 text-xs text-sidebar-accent-foreground">
{mentionsCount}
</span>
)}
{mentionsCount == 0 && isUnread && (
<span className="size-2 rounded-full bg-red-500" />
)}
</button>
);
};

View File

@@ -0,0 +1,48 @@
import { cn } from '@/lib/utils';
import { DatabaseNode } from '@colanode/core';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace';
interface DatabaseSidebarItemProps {
node: DatabaseNode;
}
export const DatabaseSidebarItem = ({ node }: DatabaseSidebarItemProps) => {
const workspace = useWorkspace();
const isActive = workspace.isNodeActive(node.id);
const isUnread = false;
const mentionsCount = 0;
return (
<button
key={node.id}
className={cn(
'flex w-full items-center',
isActive && 'bg-sidebar-accent'
)}
>
<Avatar
id={node.id}
avatar={node.attributes.avatar}
name={node.attributes.name}
className="h-4 w-4"
/>
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
isUnread && 'font-bold'
)}
>
{node.attributes.name ?? 'Unnamed'}
</span>
{mentionsCount > 0 && (
<span className="mr-1 rounded-md bg-sidebar-accent px-1 py-0.5 text-xs text-sidebar-accent-foreground">
{mentionsCount}
</span>
)}
{mentionsCount == 0 && isUnread && (
<span className="size-2 rounded-full bg-red-500" />
)}
</button>
);
};

View File

@@ -0,0 +1,48 @@
import { cn } from '@/lib/utils';
import { FolderNode } from '@colanode/core';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace';
interface FolderSidebarItemProps {
node: FolderNode;
}
export const FolderSidebarItem = ({ node }: FolderSidebarItemProps) => {
const workspace = useWorkspace();
const isActive = workspace.isNodeActive(node.id);
const isUnread = false;
const mentionsCount = 0;
return (
<button
key={node.id}
className={cn(
'flex w-full items-center',
isActive && 'bg-sidebar-accent'
)}
>
<Avatar
id={node.id}
avatar={node.attributes.avatar}
name={node.attributes.name}
className="h-4 w-4"
/>
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
isUnread && 'font-bold'
)}
>
{node.attributes.name ?? 'Unnamed'}
</span>
{mentionsCount > 0 && (
<span className="mr-1 rounded-md bg-sidebar-accent px-1 py-0.5 text-xs text-sidebar-accent-foreground">
{mentionsCount}
</span>
)}
{mentionsCount == 0 && isUnread && (
<span className="size-2 rounded-full bg-red-500" />
)}
</button>
);
};

View File

@@ -0,0 +1,48 @@
import { cn } from '@/lib/utils';
import { PageNode } from '@colanode/core';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace';
interface PageSidebarItemProps {
node: PageNode;
}
export const PageSidebarItem = ({ node }: PageSidebarItemProps) => {
const workspace = useWorkspace();
const isActive = workspace.isNodeActive(node.id);
const isUnread = false;
const mentionsCount = 0;
return (
<button
key={node.id}
className={cn(
'flex w-full items-center',
isActive && 'bg-sidebar-accent'
)}
>
<Avatar
id={node.id}
avatar={node.attributes.avatar}
name={node.attributes.name}
className="h-4 w-4"
/>
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
isUnread && 'font-bold'
)}
>
{node.attributes.name ?? 'Unnamed'}
</span>
{mentionsCount > 0 && (
<span className="mr-1 rounded-md bg-sidebar-accent px-1 py-0.5 text-xs text-sidebar-accent-foreground">
{mentionsCount}
</span>
)}
{mentionsCount == 0 && isUnread && (
<span className="size-2 rounded-full bg-red-500" />
)}
</button>
);
};

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { SpaceNode } from '@colanode/core';
import { Avatar } from '@/renderer/components/avatars/avatar';
import {
DropdownMenu,
@@ -11,7 +12,6 @@ import {
import { ChannelCreateDialog } from '@/renderer/components/channels/channel-create-dialog';
import { PageCreateDialog } from '@/renderer/components/pages/page-create-dialog';
import { DatabaseCreateDialog } from '@/renderer/components/databases/database-create-dialog';
import { SidebarSpaceNode } from '@/types/workspaces';
import { SidebarItem } from '@/renderer/components/workspaces/sidebars/sidebar-item';
import {
Collapsible,
@@ -39,20 +39,28 @@ import {
Plus,
ChevronRight,
} from 'lucide-react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@/renderer/hooks/use-query';
interface SettingsState {
open: boolean;
tab?: string;
}
interface SidebarSpaceNodeProps {
node: SidebarSpaceNode;
interface SpaceSidebarItemProps {
node: SpaceNode;
}
export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => {
export const SpaceSidebarItem = ({ node }: SpaceSidebarItemProps) => {
const workspace = useWorkspace();
const { nodeId } = useParams<{ nodeId?: string }>();
const { data } = useQuery({
type: 'node_children_get',
nodeId: node.id,
userId: workspace.userId,
types: ['page', 'channel', 'database', 'folder'],
});
const children = data ?? [];
const [openCreatePage, setOpenCreatePage] = React.useState(false);
const [openCreateChannel, setOpenCreateChannel] = React.useState(false);
@@ -73,18 +81,17 @@ export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => {
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
isActive={nodeId === node.id}
tooltip={node.name ?? ''}
tooltip={node.attributes.name ?? ''}
className="group/space-button"
>
<Avatar
id={node.id}
avatar={node.avatar}
name={node.name}
avatar={node.attributes.avatar}
name={node.attributes.name}
className="size-4 group-hover/space-button:hidden"
/>
<ChevronRight className="hidden size-4 transition-transform duration-200 group-hover/space-button:block group-data-[state=open]/collapsible:rotate-90" />
<span>{node.name}</span>
<span>{node.attributes.name}</span>
</SidebarMenuButton>
</CollapsibleTrigger>
<DropdownMenu>
@@ -97,7 +104,9 @@ export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => {
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent className="ml-1 w-72">
<DropdownMenuLabel>{node.name ?? 'Unnamed'}</DropdownMenuLabel>
<DropdownMenuLabel>
{node.attributes.name ?? 'Unnamed'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setOpenCreatePage(true)}>
<div className="flex flex-row items-center gap-2">
@@ -150,7 +159,7 @@ export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => {
<CollapsibleContent>
<SidebarMenuSub className="mr-0 pr-0">
{node.children.map((child) => (
{children.map((child) => (
<SidebarMenuSubItem
key={child.id}
onClick={() => {
@@ -158,8 +167,10 @@ export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => {
}}
className="cursor-pointer"
>
<SidebarMenuSubButton isActive={nodeId === child.id}>
<SidebarItem node={child} isActive={nodeId === child.id} />
<SidebarMenuSubButton
isActive={workspace.isNodeActive(child.id)}
>
<SidebarItem node={child} />
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
@@ -198,8 +209,8 @@ export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => {
{settingsState.open && (
<SpaceSettingsDialog
id={node.id}
name={node.name}
avatar={node.avatar}
name={node.attributes.name}
avatar={node.attributes.avatar}
open={settingsState.open}
onOpenChange={(open) =>
setSettingsState({ open, tab: settingsState.tab })

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { SidebarChatNode } from '@/types/workspaces';
import { cn } from '@/lib/utils';
import { Avatar } from '@/renderer/components/avatars/avatar';
interface SidebarChatItemProps {
node: SidebarChatNode;
isActive?: boolean;
}
export const SidebarChatItem = ({
node,
isActive,
}: SidebarChatItemProps): React.ReactNode => {
const isUnread =
!isActive && (node.unreadCount > 0 || node.mentionsCount > 0);
return (
<div
key={node.id}
className={cn(
'flex w-full items-center',
isActive && 'bg-sidebar-accent'
)}
>
<Avatar id={node.id} avatar={node.avatar} name={node.name} size="small" />
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
isUnread && 'font-bold'
)}
>
{node.name ?? 'Unnamed'}
</span>
{node.unreadCount > 0 && (
<span className="rounded-md bg-red-500 px-1 py-0.5 text-xs text-white">
{node.unreadCount}
</span>
)}
</div>
);
};

View File

@@ -1,6 +1,5 @@
import { ChatCreatePopover } from '@/renderer/components/chats/chat-create-popover';
import { useQuery } from '@/renderer/hooks/use-query';
import { SidebarChatItem } from '@/renderer/components/workspaces/sidebars/sidebar-chat-item';
import { useWorkspace } from '@/renderer/contexts/workspace';
import {
@@ -11,17 +10,21 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/renderer/components/ui/sidebar';
import { useParams } from 'react-router-dom';
import { ChatNode } from '@colanode/core';
import { ChatSidebarItem } from '@/renderer/components/chats/chat-sidebar-item';
export const SidebarChats = () => {
const workspace = useWorkspace();
const { nodeId } = useParams<{ nodeId?: string }>();
const { data } = useQuery({
type: 'sidebar_chat_list',
type: 'node_children_get',
userId: workspace.userId,
nodeId: workspace.id,
types: ['chat'],
});
const chats = data?.map((node) => node as ChatNode) ?? [];
return (
<SidebarGroup className="group/sidebar-chats group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Chats</SidebarGroupLabel>
@@ -29,15 +32,15 @@ export const SidebarChats = () => {
<ChatCreatePopover />
</SidebarGroupAction>
<SidebarMenu>
{data?.map((item) => (
<SidebarMenuItem key={item.name}>
{chats.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={nodeId === item.id}
isActive={workspace.isNodeActive(item.id)}
onClick={() => {
workspace.navigateToNode(item.id);
}}
>
<SidebarChatItem node={item} isActive={nodeId === item.id} />
<ChatSidebarItem node={item} />
</SidebarMenuButton>
</SidebarMenuItem>
))}

View File

@@ -1,50 +1,31 @@
import React from 'react';
import { SidebarNode } from '@/types/workspaces';
import { cn } from '@/lib/utils';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { Node } from '@colanode/core';
import { SpaceSidebarItem } from '@/renderer/components/spaces/space-sidebar-item';
import { ChannelSidebarItem } from '@/renderer/components/channels/channel-sidebar-item';
import { ChatSidebarItem } from '@/renderer/components/chats/chat-sidebar-item';
import { PageSidebarItem } from '@/renderer/components/pages/page-sidebar-item';
import { DatabaseSidebarItem } from '@/renderer/components/databases/database-sidiebar-item';
import { FolderSidebarItem } from '@/renderer/components/folders/folder-sidebar-item';
interface SidebarItemProps {
node: SidebarNode;
isActive?: boolean;
node: Node;
}
export const SidebarItem = ({
node,
isActive,
}: SidebarItemProps): React.ReactNode => {
const isUnread =
!isActive && (node.unreadCount > 0 || node.mentionsCount > 0);
return (
<button
key={node.id}
className={cn(
'flex w-full items-center',
isActive && 'bg-sidebar-accent'
)}
>
<Avatar
id={node.id}
avatar={node.avatar}
name={node.name}
className="h-4 w-4"
/>
<span
className={cn(
'line-clamp-1 w-full flex-grow pl-2 text-left',
isUnread && 'font-bold'
)}
>
{node.name ?? 'Unnamed'}
</span>
{node.mentionsCount > 0 && (
<span className="mr-1 rounded-md bg-sidebar-accent px-1 py-0.5 text-xs text-sidebar-accent-foreground">
{node.mentionsCount}
</span>
)}
{node.mentionsCount == 0 && isUnread && (
<span className="size-2 rounded-full bg-red-500" />
)}
</button>
);
export const SidebarItem = ({ node }: SidebarItemProps): React.ReactNode => {
switch (node.type) {
case 'space':
return <SpaceSidebarItem node={node} />;
case 'channel':
return <ChannelSidebarItem node={node} />;
case 'chat':
return <ChatSidebarItem node={node} />;
case 'page':
return <PageSidebarItem node={node} />;
case 'database':
return <DatabaseSidebarItem node={node} />;
case 'folder':
return <FolderSidebarItem node={node} />;
default:
return null;
}
};

View File

@@ -1,4 +1,3 @@
import { SidebarSpaceItem } from '@/renderer/components/workspaces/sidebars/sidebar-space-item';
import { useQuery } from '@/renderer/hooks/use-query';
import { useWorkspace } from '@/renderer/contexts/workspace';
import {
@@ -8,6 +7,8 @@ import {
SidebarMenu,
} from '@/renderer/components/ui/sidebar';
import { SpaceCreateButton } from '@/renderer/components/spaces/space-create-button';
import { SpaceNode } from '@colanode/core';
import { SpaceSidebarItem } from '@/renderer/components/spaces/space-sidebar-item';
export const SidebarSpaces = () => {
const workspace = useWorkspace();
@@ -15,10 +16,14 @@ export const SidebarSpaces = () => {
workspace.role !== 'guest' && workspace.role !== 'none';
const { data } = useQuery({
type: 'sidebar_space_list',
type: 'node_children_get',
userId: workspace.userId,
nodeId: workspace.id,
types: ['space'],
});
const spaces = data?.map((node) => node as SpaceNode) ?? [];
return (
<SidebarGroup className="group/sidebar-spaces">
<SidebarGroupLabel>Spaces</SidebarGroupLabel>
@@ -28,7 +33,9 @@ export const SidebarSpaces = () => {
</SidebarGroupAction>
)}
<SidebarMenu>
{data?.map((space) => <SidebarSpaceItem node={space} key={space.id} />)}
{spaces.map((space) => (
<SpaceSidebarItem node={space} key={space.id} />
))}
</SidebarMenu>
</SidebarGroup>
);

View File

@@ -16,7 +16,7 @@ import {
} from '@/renderer/components/ui/sidebar';
export const Workspace = () => {
const { userId } = useParams<{ userId: string }>();
const { userId, nodeId } = useParams<{ userId: string; nodeId?: string }>();
const account = useAccount();
const navigate = useNavigate();
@@ -36,6 +36,9 @@ export const Workspace = () => {
navigateToNode(nodeId) {
navigate(`/${userId}/${nodeId}`);
},
isNodeActive(id) {
return id === nodeId;
},
openModal(modal) {
setSearchParams((prev) => {
return {

View File

@@ -3,6 +3,7 @@ import { Workspace } from '@/types/workspaces';
interface WorkspaceContext extends Workspace {
navigateToNode: (nodeId: string) => void;
isNodeActive: (nodeId: string) => boolean;
openModal: (nodeId: string) => void;
openSettings: () => void;
markAsSeen: (nodeId: string, versionId: string) => void;

View File

@@ -11,35 +11,6 @@ export type Workspace = {
userId: string;
};
export type SidebarNode = {
id: string;
type: string;
name: string;
avatar: string | null;
unreadCount: number;
mentionsCount: number;
};
export type SidebarSpaceNode = SidebarNode & {
children: SidebarNode[];
};
export type SidebarChatNode = {
id: string;
type: string;
name: string | null;
avatar: string | null;
unreadCount: number;
mentionsCount: number;
};
export type BreadcrumbNode = {
id: string;
type: string;
name: string | null;
avatar: string | null;
};
export type WorkspaceCredentials = {
workspaceId: string;
accountId: string;