From a137afd65d6fa8d37f9e779591b34cc501bbac6e Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 7 Jan 2026 18:40:52 +1000 Subject: [PATCH] feat: redesign dashboard with sidebar layout and table components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new Table UI component with rich features (sorting, empty states, interactive rows) - Create DashboardLayout component with sidebar navigation - Redesign Agents page with table layout showing provider, tools, and actions - Redesign Collections page with table layout showing visibility and tool counts - Add surface-secondary color token for proper dark mode support - Add home and user icons to icon library - Fix missing foreground-quaternary with muted color 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude --- apps/web/src/app/dashboard/agents/page.tsx | 310 +++++++++++------- .../src/app/dashboard/collections/page.tsx | 245 ++++++++++---- apps/web/src/app/globals.css | 2 + apps/web/src/app/tool/tool-search/page.tsx | 2 +- .../components/dashboard/DashboardLayout.tsx | 206 ++++++++++++ packages/config/tailwind/base.ts | 1 + packages/ui/package.json | 4 + packages/ui/src/Icon/icons.ts | 8 + packages/ui/src/Table/Table.tsx | 219 +++++++++++++ 9 files changed, 801 insertions(+), 196 deletions(-) create mode 100644 apps/web/src/components/dashboard/DashboardLayout.tsx create mode 100644 packages/ui/src/Table/Table.tsx diff --git a/apps/web/src/app/dashboard/agents/page.tsx b/apps/web/src/app/dashboard/agents/page.tsx index 608e6a1..0146179 100644 --- a/apps/web/src/app/dashboard/agents/page.tsx +++ b/apps/web/src/app/dashboard/agents/page.tsx @@ -1,12 +1,22 @@ 'use client'; import type { AIProvider } from '@tpmjs/types/agent'; +import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { + Table, + TableBody, + TableCell, + TableEmpty, + TableHead, + TableHeader, + TableRow, +} from '@tpmjs/ui/Table/Table'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; -import { AppHeader } from '~/components/AppHeader'; +import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; interface Agent { id: string; @@ -29,6 +39,23 @@ const PROVIDER_DISPLAY_NAMES: Record = { MISTRAL: 'Mistral', }; +const PROVIDER_COLORS: Record = { + OPENAI: 'default', + ANTHROPIC: 'secondary', + GOOGLE: 'outline', + GROQ: 'outline', + MISTRAL: 'outline', +}; + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + export default function AgentsPage(): React.ReactElement { const router = useRouter(); const [agents, setAgents] = useState([]); @@ -62,7 +89,8 @@ export default function AgentsPage(): React.ReactElement { fetchAgents(); }, [fetchAgents]); - const handleDelete = async (id: string) => { + const handleDelete = async (id: string, e: React.MouseEvent) => { + e.stopPropagation(); if (!confirm('Are you sure you want to delete this agent? This action cannot be undone.')) { return; } @@ -88,144 +116,172 @@ export default function AgentsPage(): React.ReactElement { } }; - if (isLoading) { - return ( -
- -
-
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
-
- ); - } - if (error) { return ( -
- -
-
- -

Error

-

{error}

- -
-
-
- ); - } - - return ( -
- -
- {/* Header */} -
-
- - - -

My Agents

-
+ + } + > +
+ +

Error

+

{error}

+
+
+ ); + } - {/* Empty State */} - {agents.length === 0 && ( -
-
- -
-

No agents yet

-

- Create your first AI agent to start chatting with tools. Agents can use any tools from - your collections or individual tools. -

- - - -
- )} - - {/* Agents Grid */} - {agents.length > 0 && ( -
- {agents.map((agent) => ( -
-
-
-
- -
-
-

{agent.name}

-

- {PROVIDER_DISPLAY_NAMES[agent.provider]} / {agent.modelId} -

-
+ return ( + 0 ? `${agents.length} agent${agents.length !== 1 ? 's' : ''}` : undefined + } + actions={ + + + + } + > +
+ + + + Name + Provider + Tools + Updated + Actions + + + + {isLoading ? ( + // Loading skeleton + <> + {[0, 1, 2].map((idx) => ( + + +
+
+
+
+
+
+
+ + +
+ + +
+ + +
+ + +
+ + + ))} + + ) : agents.length === 0 ? ( + +
-
- - {agent.description && ( -

- {agent.description} -

- )} - -
- - - {agent.toolCount + agent.collectionCount * 5} tools - -
- -
- - - - - - -
-
- ))} -
- )} + } + /> + ) : ( + agents.map((agent) => ( + router.push(`/dashboard/agents/${agent.id}`)} + className="cursor-pointer" + > + +
+
+ +
+
+

{agent.name}

+ {agent.description && ( +

+ {agent.description} +

+ )} +
+
+
+ +
+ + {PROVIDER_DISPLAY_NAMES[agent.provider]} + + {agent.modelId} +
+
+ + + {agent.toolCount + agent.collectionCount * 5} + + + + + {formatDate(agent.updatedAt)} + + + +
+ e.stopPropagation()} + > + + + +
+
+
+ )) + )} + +
-
+ ); } diff --git a/apps/web/src/app/dashboard/collections/page.tsx b/apps/web/src/app/dashboard/collections/page.tsx index 3e865d7..ca11ec5 100644 --- a/apps/web/src/app/dashboard/collections/page.tsx +++ b/apps/web/src/app/dashboard/collections/page.tsx @@ -1,13 +1,21 @@ 'use client'; +import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; -import Link from 'next/link'; +import { + Table, + TableBody, + TableCell, + TableEmpty, + TableHead, + TableHeader, + TableRow, +} from '@tpmjs/ui/Table/Table'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; -import { AppHeader } from '~/components/AppHeader'; import { CollectionForm } from '~/components/collections/CollectionForm'; -import { CollectionList } from '~/components/collections/CollectionList'; +import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; interface Collection { id: string; @@ -18,6 +26,15 @@ interface Collection { updatedAt: string; } +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + export default function CollectionsPage(): React.ReactElement { const router = useRouter(); const [collections, setCollections] = useState([]); @@ -79,7 +96,8 @@ export default function CollectionsPage(): React.ReactElement { } }; - const handleDelete = async (id: string) => { + const handleDelete = async (id: string, e: React.MouseEvent) => { + e.stopPropagation(); if ( !confirm('Are you sure you want to delete this collection? This action cannot be undone.') ) { @@ -108,79 +126,170 @@ export default function CollectionsPage(): React.ReactElement { } }; - if (isLoading) { - return ( -
- -
-
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
-
- ); - } - if (error) { return ( -
- -
-
- -

Error

-

{error}

- -
+ setShowCreateForm(true)}> + + New Collection + + } + > +
+ +

Error

+

{error}

+
-
+ ); } return ( -
- -
- {/* Header */} -
-
- - - -

My Collections

-
- {!showCreateForm && ( - - )} + 0 + ? `${collections.length} collection${collections.length !== 1 ? 's' : ''}` + : undefined + } + actions={ + !showCreateForm && ( + + ) + } + > + {/* Create Form */} + {showCreateForm && ( +
+

Create New Collection

+ setShowCreateForm(false)} + isSubmitting={isCreating} + submitLabel="Create Collection" + />
+ )} - {/* Create Form */} - {showCreateForm && ( -
-

Create New Collection

- setShowCreateForm(false)} - isSubmitting={isCreating} - submitLabel="Create Collection" - /> -
- )} - - {/* Collections List */} - + {/* Collections Table */} +
+ + + + Name + Tools + Visibility + Updated + Actions + + + + {isLoading ? ( + // Loading skeleton + <> + {[0, 1, 2].map((idx) => ( + + +
+
+
+
+
+
+
+ + +
+ + +
+ + +
+ + +
+ + + ))} + + ) : collections.length === 0 ? ( + + +
+ } + title="No collections yet" + description="Create a collection to organize your tools. Collections make it easy to group related tools together and share them with your agents." + action={ + + } + /> + ) : ( + collections.map((collection) => ( + router.push(`/dashboard/collections/${collection.id}`)} + className="cursor-pointer" + > + +
+
+ +
+
+

{collection.name}

+ {collection.description && ( +

+ {collection.description} +

+ )} +
+
+
+ + {collection.toolCount} + + + + {collection.isPublic ? 'Public' : 'Private'} + + + + + {formatDate(collection.updatedAt)} + + + +
+ +
+
+
+ )) + )} + +
-
+ ); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 61d688f..ca46d03 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -8,6 +8,7 @@ /* Backgrounds & Surfaces - DRAMATIC CONTRAST */ --background: 220 15% 96%; /* Light blue-gray background */ --surface: 0 0% 100%; /* Pure white - cards really pop! */ + --surface-secondary: 220 15% 94%; /* Subtle gray for secondary surfaces */ --surface-elevated: 0 0% 100%; /* White (elevated) */ --surface-overlay: 0 0% 100%; /* White overlays */ @@ -103,6 +104,7 @@ /* Backgrounds & Surfaces */ --background: 210 10% 5%; /* #0d0d0f - Almost black */ --surface: 210 10% 8%; /* #14141a - Slightly lighter */ + --surface-secondary: 210 10% 10%; /* Secondary surfaces */ --surface-elevated: 210 10% 12%; /* #1c1c24 - Cards, modals */ --surface-overlay: 210 10% 16%; /* #25252f - Overlays, popovers */ diff --git a/apps/web/src/app/tool/tool-search/page.tsx b/apps/web/src/app/tool/tool-search/page.tsx index d35d1b8..5d7fb36 100644 --- a/apps/web/src/app/tool/tool-search/page.tsx +++ b/apps/web/src/app/tool/tool-search/page.tsx @@ -369,7 +369,7 @@ export default function ToolSearchPage(): React.ReactElement { {/* Icon/Visual Element */}
-
+
diff --git a/apps/web/src/components/dashboard/DashboardLayout.tsx b/apps/web/src/components/dashboard/DashboardLayout.tsx new file mode 100644 index 0000000..8adde59 --- /dev/null +++ b/apps/web/src/components/dashboard/DashboardLayout.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useSession } from '@/lib/auth-client'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon, type IconName } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { AppHeader } from '../AppHeader'; + +interface NavItem { + href: string; + label: string; + icon: IconName; + badge?: number; +} + +const navItems: NavItem[] = [ + { href: '/dashboard', label: 'Overview', icon: 'home' }, + { href: '/dashboard/agents', label: 'Agents', icon: 'terminal' }, + { href: '/dashboard/collections', label: 'Collections', icon: 'folder' }, + { href: '/dashboard/settings/api-keys', label: 'API Keys', icon: 'key' }, +]; + +interface DashboardLayoutProps { + children: React.ReactNode; + /** Title displayed in the header */ + title: string; + /** Optional subtitle/description */ + subtitle?: string; + /** Action buttons for the header */ + actions?: React.ReactNode; + /** Whether to show back button */ + showBackButton?: boolean; + /** Custom back URL (defaults to parent route) */ + backUrl?: string; +} + +export function DashboardLayout({ + children, + title, + subtitle, + actions, + showBackButton, + backUrl, +}: DashboardLayoutProps): React.ReactElement { + const pathname = usePathname(); + const router = useRouter(); + const { data: session, isPending } = useSession(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + // Redirect to sign-in if not authenticated + useEffect(() => { + if (!isPending && !session) { + router.push('/sign-in'); + } + }, [isPending, session, router]); + + // Close sidebar on route change - pathname dependency triggers this effect + // biome-ignore lint/correctness/useExhaustiveDependencies: pathname triggers effect + useEffect(() => { + setSidebarOpen(false); + }, [pathname]); + + const isActive = (href: string) => { + if (href === '/dashboard') { + return pathname === '/dashboard'; + } + return pathname.startsWith(href); + }; + + const getBackUrl = () => { + if (backUrl) return backUrl; + // Get parent route + const parts = pathname.split('/').filter(Boolean); + parts.pop(); + return parts.length > 0 ? `/${parts.join('/')}` : '/dashboard'; + }; + + if (isPending) { + return ( +
+ +
+
Loading...
+
+
+ ); + } + + if (!session) { + return
; + } + + return ( +
+ + +
+ {/* Mobile sidebar backdrop */} + {sidebarOpen && ( + + + {/* Back button */} + {showBackButton && ( + + + + )} + + {/* Title */} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ + {/* Actions */} + {actions &&
{actions}
} +
+
+
+ + {/* Page content */} +
{children}
+ +
+
+ ); +} diff --git a/packages/config/tailwind/base.ts b/packages/config/tailwind/base.ts index e5367a0..05e2913 100644 --- a/packages/config/tailwind/base.ts +++ b/packages/config/tailwind/base.ts @@ -25,6 +25,7 @@ export default { // Backgrounds & Surfaces background: 'hsl(var(--background))', surface: 'hsl(var(--surface))', + 'surface-secondary': 'hsl(var(--surface-secondary))', 'surface-elevated': 'hsl(var(--surface-elevated))', 'surface-overlay': 'hsl(var(--surface-overlay))', diff --git a/packages/ui/package.json b/packages/ui/package.json index b80d6a5..6d587c0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -156,6 +156,10 @@ "./ToolHealthBanner/ToolHealthBanner": { "types": "./dist/ToolHealthBanner/ToolHealthBanner.d.ts", "default": "./dist/ToolHealthBanner/ToolHealthBanner.js" + }, + "./Table/Table": { + "types": "./dist/Table/Table.d.ts", + "default": "./dist/Table/Table.js" } }, "files": ["dist"], diff --git a/packages/ui/src/Icon/icons.ts b/packages/ui/src/Icon/icons.ts index 374f30a..a5875c5 100644 --- a/packages/ui/src/Icon/icons.ts +++ b/packages/ui/src/Icon/icons.ts @@ -120,6 +120,14 @@ export const icons = { viewBox: '0 0 24 24', path: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z', }, + home: { + viewBox: '0 0 24 24', + path: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z', + }, + user: { + viewBox: '0 0 24 24', + path: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z', + }, } as const; export type IconName = keyof typeof icons; diff --git a/packages/ui/src/Table/Table.tsx b/packages/ui/src/Table/Table.tsx new file mode 100644 index 0000000..3db8d82 --- /dev/null +++ b/packages/ui/src/Table/Table.tsx @@ -0,0 +1,219 @@ +import { cn } from '@tpmjs/utils/cn'; +import { forwardRef } from 'react'; + +// ============================================================================ +// Table Root +// ============================================================================ + +export interface TableProps extends React.HTMLAttributes { + /** Visual style variant */ + variant?: 'default' | 'bordered'; +} + +const Table = forwardRef( + ({ className, variant = 'default', ...props }, ref) => ( +
+ + + ) +); +Table.displayName = 'Table'; + +// ============================================================================ +// Table Header +// ============================================================================ + +const TableHeader = forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +// ============================================================================ +// Table Body +// ============================================================================ + +const TableBody = forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +// ============================================================================ +// Table Footer +// ============================================================================ + +const TableFooter = forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableFooter.displayName = 'TableFooter'; + +// ============================================================================ +// Table Row +// ============================================================================ + +export interface TableRowProps extends React.HTMLAttributes { + /** Whether the row is selected */ + selected?: boolean; + /** Whether the row is clickable/interactive */ + interactive?: boolean; +} + +const TableRow = forwardRef( + ({ className, selected, interactive, ...props }, ref) => ( + + ) +); +TableRow.displayName = 'TableRow'; + +// ============================================================================ +// Table Head Cell +// ============================================================================ + +export interface TableHeadProps extends React.ThHTMLAttributes { + /** Whether the column is sortable */ + sortable?: boolean; + /** Current sort direction */ + sortDirection?: 'asc' | 'desc' | null; +} + +const TableHead = forwardRef( + ({ className, sortable, sortDirection, children, ...props }, ref) => ( + + ) +); +TableHead.displayName = 'TableHead'; + +// ============================================================================ +// Table Cell +// ============================================================================ + +const TableCell = forwardRef>( + ({ className, ...props }, ref) => ( + + + + ) +); +TableEmpty.displayName = 'TableEmpty'; + +// ============================================================================ +// Exports +// ============================================================================ + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableRow, + TableHead, + TableCell, + TableCaption, + TableEmpty, +};
+ {sortable ? ( +
+ {children} + + {sortDirection === 'asc' && '↑'} + {sortDirection === 'desc' && '↓'} + {!sortDirection && '↕'} + +
+ ) : ( + children + )} +
+ ) +); +TableCell.displayName = 'TableCell'; + +// ============================================================================ +// Table Caption +// ============================================================================ + +const TableCaption = forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = 'TableCaption'; + +// ============================================================================ +// Table Empty State +// ============================================================================ + +export interface TableEmptyProps extends React.HTMLAttributes { + /** Number of columns to span */ + colSpan: number; + /** Icon to display */ + icon?: React.ReactNode; + /** Title text */ + title: string; + /** Description text */ + description?: string; + /** Action button/element */ + action?: React.ReactNode; +} + +const TableEmpty = forwardRef( + ({ className, colSpan, icon, title, description, action, ...props }, ref) => ( +
+ {icon &&
{icon}
} +

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +