tabs improvements

This commit is contained in:
Hakan Shehu
2025-10-03 21:29:17 +02:00
parent 78c515dacd
commit 0e9c2693a6
19 changed files with 479 additions and 146 deletions

View File

@@ -0,0 +1,21 @@
import { LocalChannelNode } from '@colanode/client/types';
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
interface ChannelTabProps {
channel: LocalChannelNode;
}
export const ChannelTab = ({ channel }: ChannelTabProps) => {
const name =
channel.attributes.name && channel.attributes.name.length > 0
? channel.attributes.name
: 'Unnamed';
return (
<Tab
id={channel.id}
avatar={channel.attributes.avatar}
name={name}
/>
);
};

View File

@@ -0,0 +1,34 @@
import { LocalChatNode } from '@colanode/client/types';
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useLiveQuery } from '@colanode/ui/hooks/use-live-query';
interface ChatTabProps {
chat: LocalChatNode;
}
export const ChatTab = ({ chat }: ChatTabProps) => {
const workspace = useWorkspace();
const userId =
chat.type === 'chat'
? (Object.keys(chat.attributes.collaborators).find(
(id) => id !== workspace.userId
) ?? '')
: '';
const userGetQuery = useLiveQuery({
type: 'user.get',
accountId: workspace.accountId,
workspaceId: workspace.id,
userId,
});
if (userGetQuery.isPending || !userGetQuery.data) {
return null;
}
const user = userGetQuery.data;
return <Tab id={user.id} avatar={user.avatar} name={user.name} />;
};

View File

@@ -0,0 +1,21 @@
import { LocalDatabaseNode } from '@colanode/client/types';
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
interface DatabaseTabProps {
database: LocalDatabaseNode;
}
export const DatabaseTab = ({ database }: DatabaseTabProps) => {
const name =
database.attributes.name && database.attributes.name.length > 0
? database.attributes.name
: 'Untitled';
return (
<Tab
id={database.id}
avatar={database.attributes.avatar}
name={name}
/>
);
};

View File

@@ -0,0 +1,20 @@
import { LocalFileNode } from '@colanode/client/types';
import { FileThumbnail } from '@colanode/ui/components/files/file-thumbnail';
interface FileTabProps {
file: LocalFileNode;
}
export const FileTab = ({ file }: FileTabProps) => {
const name =
file.attributes.name && file.attributes.name.length > 0
? file.attributes.name
: 'Untitled';
return (
<div className="flex items-center space-x-2">
<FileThumbnail file={file} className="size-4 rounded object-contain" />
<span>{name}</span>
</div>
);
};

View File

@@ -0,0 +1,17 @@
import { LocalFolderNode } from '@colanode/client/types';
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
interface FolderTabProps {
folder: LocalFolderNode;
}
export const FolderTab = ({ folder }: FolderTabProps) => {
const name =
folder.attributes.name && folder.attributes.name.length > 0
? folder.attributes.name
: 'Untitled';
return (
<Tab id={folder.id} avatar={folder.attributes.avatar} name={name} />
);
};

View File

@@ -0,0 +1,17 @@
import { Plus } from 'lucide-react';
interface LayoutAddTabButtonProps {
onAddTab: () => void;
}
export const LayoutAddTabButton = ({ onAddTab }: LayoutAddTabButtonProps) => {
return (
<button
onClick={onAddTab}
className="flex items-center justify-center w-10 h-10 bg-sidebar hover:bg-sidebar-accent transition-all duration-200 app-no-drag-region flex-shrink-0 border-l border-border/30 hover:border-border/60 rounded-tl-md"
title="Add new tab"
>
<Plus className="size-4 text-muted-foreground hover:text-foreground transition-colors" />
</button>
);
};

View File

@@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { router } from '@colanode/ui/router';
interface TabBarContentProps {
location: string;
router: typeof router;
}
export const LayoutTabBarContent = ({
location,
router,
}: TabBarContentProps) => {
const tabComponent = useMemo(() => {
const matches = router.matchRoutes(location);
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
if (match?.context && 'tab' in match.context) {
return match.context.tab;
}
}
return null;
}, [location]);
return (
<div className="truncate text-sm font-medium">
{tabComponent || 'New tab'}
</div>
);
};

View File

@@ -0,0 +1,73 @@
import { X } from 'lucide-react';
import { Tab } from '@colanode/client/types';
import { LayoutTabBarContent } from '@colanode/ui/components/layouts/layout-tab-bar-content';
import { cn } from '@colanode/ui/lib/utils';
import { router } from '@colanode/ui/router';
interface LayoutTabBarProps {
tab: Tab;
router: typeof router;
index: number;
isActive: boolean;
isLast: boolean;
onClick: () => void;
onDelete: () => void;
}
export const LayoutTabBar = ({
tab,
router,
index,
isActive,
isLast,
onClick,
onDelete,
}: LayoutTabBarProps) => {
return (
<div
key={tab.id}
className={cn(
'relative group/tab app-no-drag-region flex items-center gap-2 px-4 py-2 cursor-pointer transition-all duration-200 min-w-[120px] max-w-[240px] flex-1',
// Active tab styling with proper z-index for overlapping
isActive
? 'bg-background text-foreground z-20 shadow-[0_-2px_8px_rgba(0,0,0,0.1),0_2px_4px_rgba(0,0,0,0.05)] border-t border-l border-r border-border'
: 'bg-sidebar-accent/60 text-muted-foreground hover:bg-sidebar-accent hover:text-foreground z-10 hover:z-15 shadow-[0_1px_3px_rgba(0,0,0,0.1)]',
// Add overlap effect - each tab overlaps the previous one
index > 0 && '-ml-3',
// Ensure proper stacking order
`relative`
)}
style={{
clipPath: isActive
? 'polygon(12px 0%, calc(100% - 12px) 0%, 100% 100%, 0% 100%)'
: 'polygon(12px 0%, calc(100% - 12px) 0%, calc(100% - 6px) 100%, 6px 100%)',
}}
onClick={onClick}
>
{/* Tab content */}
<div className="flex items-center gap-2 flex-1 min-w-0 z-10">
<LayoutTabBarContent location={tab.location} router={router} />
<button
className={cn(
'opacity-0 group-hover/tab:opacity-100 transition-all duration-200 flex-shrink-0 rounded-full p-1 hover:bg-destructive/20 hover:text-destructive',
isActive && 'opacity-70 hover:opacity-100',
'ml-auto' // Push to the right edge
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
title="Close tab"
>
<X className="size-3" />
</button>
</div>
{/* Browser-like tab separator */}
{!isActive && !isLast && (
<div className="absolute right-0 top-2 bottom-2 w-px bg-border/50" />
)}
</div>
);
};

View File

@@ -1,46 +0,0 @@
import {
createMemoryHistory,
createRouter,
RouterProvider,
} from '@tanstack/react-router';
import { useMemo } from 'react';
import { routeTree } from '@colanode/ui/router';
interface LayoutTabContentProps {
location: string;
onChange: (location: string) => void;
}
export const LayoutTabContent = ({
location,
onChange,
}: LayoutTabContentProps) => {
const router = useMemo(() => {
const history = createMemoryHistory({
initialEntries: [location],
});
const router = createRouter({
routeTree,
context: {},
history,
defaultPreload: 'intent',
scrollRestoration: true,
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
});
router.subscribe('onRendered', (event) => {
if (!event.hrefChanged) {
return;
}
onChange(event.toLocation.href);
});
return router;
}, []);
return <RouterProvider router={router} />;
};

View File

@@ -1,11 +0,0 @@
interface LayoutTabProps {
location: string;
}
const LayoutTabContent = ({ location }: LayoutTabProps) => {
return <div>LayoutTabContent</div>;
};
export const LayoutTab = ({ location }: LayoutTabProps) => {
return <div>LayoutTab</div>;
};

View File

@@ -1,5 +1,9 @@
import { Plus, X } from 'lucide-react';
import { useCallback } from 'react';
import {
createMemoryHistory,
createRouter,
RouterProvider,
} from '@tanstack/react-router';
import { useCallback, useRef } from 'react';
import { Tab } from '@colanode/client/types';
import {
@@ -8,18 +12,56 @@ import {
generateId,
IdType,
} from '@colanode/core';
import { LayoutTabContent } from '@colanode/ui/components/layouts/layout-tab-content';
import { LayoutAddTabButton } from '@colanode/ui/components/layouts/layout-add-tab-button';
import { LayoutTabBar } from '@colanode/ui/components/layouts/layout-tab-bar';
import { cn } from '@colanode/ui/lib/utils';
import { router, routeTree } from '@colanode/ui/router';
import { useAppStore } from '@colanode/ui/stores/app';
export const LayoutTabs = () => {
const allTabs = useAppStore((state) => state.tabs);
const activeTabId = useAppStore((state) => state.metadata.tab);
const tabs = Object.values(allTabs).sort((a, b) =>
compareString(a.index, b.index)
);
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]!;
const routers = useRef<Record<string, typeof router>>(
tabs.reduce(
(acc, tab) => {
const router = createRouter({
routeTree,
context: {},
history: createMemoryHistory({ initialEntries: [tab.location] }),
defaultPreload: 'intent',
scrollRestoration: true,
defaultStructuralSharing: false,
defaultPreloadStaleTime: 0,
});
router.subscribe('onRendered', (event) => {
if (!event.hrefChanged) {
return;
}
const location = event.toLocation.href;
useAppStore.getState().upsertTab({
...tab,
location,
});
window.colanode.executeMutation({
type: 'tab.update',
id: tab.id,
location,
});
});
acc[tab.id] = router;
return acc;
},
{} as Record<string, typeof router>
)
);
const deleteTab = useCallback((tabId: string) => {
const currentTabs = useAppStore.getState().tabs;
@@ -77,99 +119,46 @@ export const LayoutTabs = () => {
});
}, []);
const updateTabLocation = useCallback((tabId: string, location: string) => {
const allTabs = useAppStore.getState().tabs;
const tab = allTabs[tabId];
if (!tab) {
return;
}
useAppStore.getState().upsertTab({
...tab,
location,
});
window.colanode.executeMutation({
type: 'tab.update',
id: tabId,
location,
});
}, []);
return (
<div className="flex flex-col h-full">
{/* Tab bar with browser-like styling */}
<div className="relative flex bg-sidebar border-b border-border h-10 overflow-hidden">
{tabs.map((tab, index) => {
const isActive = tab.id === activeTab.id;
return (
<div
key={tab.id}
className={cn(
'relative group/tab app-no-drag-region flex items-center gap-2 px-4 py-2 cursor-pointer transition-all duration-200 min-w-[120px] max-w-[240px] flex-1',
// Active tab styling with proper z-index for overlapping
isActive
? 'bg-background text-foreground z-20 shadow-[0_-2px_8px_rgba(0,0,0,0.1),0_2px_4px_rgba(0,0,0,0.05)] border-t border-l border-r border-border'
: 'bg-sidebar-accent/60 text-muted-foreground hover:bg-sidebar-accent hover:text-foreground z-10 hover:z-15 shadow-[0_1px_3px_rgba(0,0,0,0.1)]',
// Add overlap effect - each tab overlaps the previous one
index > 0 && '-ml-3',
// Ensure proper stacking order
`relative`
)}
style={{
clipPath: isActive
? 'polygon(12px 0%, calc(100% - 12px) 0%, 100% 100%, 0% 100%)'
: 'polygon(12px 0%, calc(100% - 12px) 0%, calc(100% - 6px) 100%, 6px 100%)',
}}
onClick={() => switchTab(tab.id)}
>
{/* Tab content */}
<div className="flex items-center gap-2 flex-1 min-w-0 z-10">
<div className="truncate text-sm font-medium">
Tab {index + 1}
</div>
<button
className={cn(
'opacity-0 group-hover/tab:opacity-100 transition-all duration-200 flex-shrink-0 rounded-full p-1 hover:bg-destructive/20 hover:text-destructive',
isActive && 'opacity-70 hover:opacity-100',
'ml-auto' // Push to the right edge
)}
onClick={(e) => {
e.stopPropagation();
deleteTab(tab.id);
}}
title="Close tab"
>
<X className="size-3" />
</button>
</div>
const isLast =
index === tabs.length - 1 || tabs[index + 1]?.id === activeTab.id;
{/* Browser-like tab separator */}
{!isActive &&
index < tabs.length - 1 &&
tabs[index + 1]?.id !== activeTab.id && (
<div className="absolute right-0 top-2 bottom-2 w-px bg-border/50" />
)}
</div>
const router = routers.current[tab.id];
if (!router) {
return null;
}
return (
<LayoutTabBar
key={tab.id}
tab={tab}
router={router}
index={index}
isActive={isActive}
isLast={isLast}
onClick={() => switchTab(tab.id)}
onDelete={() => deleteTab(tab.id)}
/>
);
})}
{/* Add tab button */}
<button
onClick={addTab}
className="flex items-center justify-center w-10 h-10 bg-sidebar hover:bg-sidebar-accent transition-all duration-200 app-no-drag-region flex-shrink-0 border-l border-border/30 hover:border-border/60 rounded-tl-md"
title="Add new tab"
>
<Plus className="size-4 text-muted-foreground hover:text-foreground transition-colors" />
</button>
{/* Tab bar background with subtle gradient */}
<LayoutAddTabButton onAddTab={addTab} />
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-background/5 to-border/10" />
</div>
<div className="flex-1 overflow-hidden relative">
{tabs.map((tab) => {
const isActive = tab.id === activeTab.id;
const router = routers.current[tab.id];
if (!router) {
return null;
}
return (
<div
key={tab.id}
@@ -181,10 +170,7 @@ export const LayoutTabs = () => {
pointerEvents: isActive ? 'auto' : 'none',
}}
>
<LayoutTabContent
location={tab.location}
onChange={(location) => updateTabLocation(tab.id, location)}
/>
<RouterProvider router={router} />
</div>
);
})}

View File

@@ -0,0 +1,16 @@
import { Avatar } from '@colanode/ui/components/avatars/avatar';
interface TabProps {
id: string;
avatar?: string | null;
name: string;
}
export const Tab = ({ id, avatar, name }: TabProps) => {
return (
<div className="flex items-center space-x-2">
<Avatar id={id} avatar={avatar} name={name} className="size-4" />
<span>{name}</span>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import { MessageCircle } from 'lucide-react';
import { LocalMessageNode } from '@colanode/client/types';
interface MessageTabProps {
message: LocalMessageNode;
}
export const MessageTab = ({ message }: MessageTabProps) => {
return (
<div className="flex items-center space-x-2" id={message.id}>
<MessageCircle className="size-4" />
<span>Message</span>
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { ChannelTab } from '@colanode/ui/components/channels/channel-tab';
import { ChatTab } from '@colanode/ui/components/chats/chat-tab';
import { DatabaseTab } from '@colanode/ui/components/databases/database-tab';
import { FileTab } from '@colanode/ui/components/files/file-tab';
import { FolderTab } from '@colanode/ui/components/folders/folder-tab';
import { MessageTab } from '@colanode/ui/components/messages/message-tab';
import { PageTab } from '@colanode/ui/components/pages/page-tab';
import { RecordTab } from '@colanode/ui/components/records/record-tab';
import { SpaceTab } from '@colanode/ui/components/spaces/space-tab';
import { useLiveQuery } from '@colanode/ui/hooks/use-live-query';
interface NodeTabProps {
accountId: string;
workspaceId: string;
nodeId: string;
}
export const NodeTab = ({ accountId, workspaceId, nodeId }: NodeTabProps) => {
const query = useLiveQuery({
type: 'node.get',
accountId,
workspaceId,
nodeId,
});
if (query.isPending) {
return null;
}
const node = query.data;
if (!node) {
return null;
}
switch (node.type) {
case 'space':
return <SpaceTab space={node} />;
case 'channel':
return <ChannelTab channel={node} />;
case 'chat':
return <ChatTab chat={node} />;
case 'page':
return <PageTab page={node} />;
case 'database':
return <DatabaseTab database={node} />;
case 'record':
return <RecordTab record={node} />;
case 'folder':
return <FolderTab folder={node} />;
case 'file':
return <FileTab file={node} />;
case 'message':
return <MessageTab message={node} />;
default:
return null;
}
};

View File

@@ -0,0 +1,17 @@
import { LocalPageNode } from '@colanode/client/types';
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
interface PageTabProps {
page: LocalPageNode;
}
export const PageTab = ({ page }: PageTabProps) => {
const name =
page.attributes.name && page.attributes.name.length > 0
? page.attributes.name
: 'Untitled';
return (
<Tab id={page.id} avatar={page.attributes.avatar} name={name} />
);
};

View File

@@ -0,0 +1,17 @@
import { LocalRecordNode } from '@colanode/client/types';
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
interface RecordTabProps {
record: LocalRecordNode;
}
export const RecordTab = ({ record }: RecordTabProps) => {
const name =
record.attributes.name && record.attributes.name.length > 0
? record.attributes.name
: 'Untitled';
return (
<Tab id={record.id} avatar={record.attributes.avatar} name={name} />
);
};

View File

@@ -0,0 +1,16 @@
import { LocalSpaceNode } from '@colanode/client/types';
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
interface SpaceTabProps {
space: LocalSpaceNode;
}
export const SpaceTab = ({ space }: SpaceTabProps) => {
return (
<Tab
id={space.id}
avatar={space.attributes.avatar}
name={space.attributes.name}
/>
);
};

View File

@@ -1,7 +1,21 @@
import { Tab } from '@colanode/ui/components/layouts/tabs/tab';
import { useAppStore } from '@colanode/ui/stores/app';
interface WorkspaceTabProps {
accountId: string;
workspaceId: string;
}
export const WorkspaceTab = ({ workspaceId }: WorkspaceTabProps) => {
return <div>WorkspaceTab {workspaceId}</div>;
export const WorkspaceTab = ({ accountId, workspaceId }: WorkspaceTabProps) => {
const workspace = useAppStore(
(state) => state.accounts[accountId]?.workspaces[workspaceId]
);
if (!workspace) {
return null;
}
return (
<Tab id={workspace.id} avatar={workspace.avatar} name={workspace.name} />
);
};

View File

@@ -14,6 +14,7 @@ import { LoginScreen } from '@colanode/ui/components/accounts/login-screen';
import { AppAppearanceSettingsScreen } from '@colanode/ui/components/app/app-appearance-settings-screen';
import { NodeErrorScreen } from '@colanode/ui/components/nodes/node-error-screen';
import { NodeScreen } from '@colanode/ui/components/nodes/node-screen';
import { NodeTab } from '@colanode/ui/components/nodes/node-tab';
import { WorkspaceDownloadsScreen } from '@colanode/ui/components/workspaces/downloads/workspace-downloads-screen';
import { WorkspaceStorageScreen } from '@colanode/ui/components/workspaces/storage/workspace-storage-screen';
import { WorkspaceUploadsScreen } from '@colanode/ui/components/workspaces/uploads/workspace-uploads-screen';
@@ -143,7 +144,12 @@ export const workspaceRoute = createRoute({
component: WorkspaceScreen,
context: (ctx) => {
return {
tab: <WorkspaceTab workspaceId={ctx.params.workspaceId} />,
tab: (
<WorkspaceTab
accountId={ctx.params.accountId}
workspaceId={ctx.params.workspaceId}
/>
),
};
},
});
@@ -252,6 +258,17 @@ export const nodeRoute = createRoute({
path: '/$nodeId',
component: NodeScreen,
errorComponent: NodeErrorScreen,
context: (ctx) => {
return {
tab: (
<NodeTab
accountId={ctx.params.accountId}
workspaceId={ctx.params.workspaceId}
nodeId={ctx.params.nodeId}
/>
),
};
},
});
export const nodeMaskRoute = createRoute({