Workspaces left panel in homepage

This commit is contained in:
2026-02-18 20:00:42 +00:00
parent 7092c2665a
commit 72bca6cd42
8 changed files with 85 additions and 257 deletions
@@ -6,10 +6,21 @@ import { appRegistry } from '../Workspaces/app-registry';
const initialLayout: LayoutNode = {
type: 'group',
id: 'home-root',
direction: 'vertical',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
{ node: { type: 'panel', id: 'home-bottom', appType: 'chat-launcher' }, size: 40 },
{ node: { type: 'panel', id: 'home-left', appType: 'workspace-list' }, size: 20 },
{
node: {
type: 'group',
id: 'home-right',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
],
},
size: 80,
},
],
};
@@ -1,144 +0,0 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import {
User,
LogOut,
Terminal,
TerminalSquare,
FileText,
FolderOpen,
Server,
Package,
Bot,
Sparkles,
ClipboardList,
ScrollText,
Workflow,
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { UserWithToken } from 'hooks/useAuth';
import { useAuth } from 'hooks/useAuth';
import { PixelGrid } from '../LandingPage/components/PixelGrid';
import { useIsProduction } from 'hooks/useIsProduction';
import { useServerSettings } from '@/state/useServerSettings';
import { PasskeyGate } from './PasskeyGate';
import { Dock, type DockItem } from './Dock';
type DashboardLayoutProps = {
children?: ReactNode;
mobileFull?: boolean;
};
export function DashboardLayout({ children, mobileFull }: DashboardLayoutProps) {
const { user } = useAuth();
const isProduction = useIsProduction();
const { plugins } = useServerSettings();
const passkeyCount = (user as UserWithToken & { passkeyCount?: number })?.passkeyCount ?? 0;
const dockItems: DockItem[] = [
{ label: 'Chat', to: '/chat', icon: Terminal, color: '#60a5fa' },
...(plugins?.FileBrowser !== false
? [{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' } as DockItem]
: []),
...(plugins?.Terminal !== false
? [{ label: 'Terminal', to: '/terminal', icon: TerminalSquare, color: '#34d399' } as DockItem]
: []),
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Skills', to: '/skills', icon: Sparkles, color: '#c084fc' },
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
{ label: 'Processes', to: '/processes', icon: Workflow, color: '#2dd4bf' },
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
];
return (
<div className="relative overflow-hidden h-dvh outline-none fixed inset-0">
<PixelGrid />
<section className="relative h-dvh snap-start overflow-hidden">
{/* Background layer */}
<div
className="absolute inset-0 z-0"
style={{
backgroundImage: 'var(--page-bg-image)',
backgroundSize: 'cover',
backgroundPosition: 'center center',
}}
/>
{/* Content layer - above pixel grid */}
<div className="absolute inset-0 z-[520] flex flex-col">
<header
className="shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
style={{ backgroundColor: 'var(--glass-bg)', borderColor: 'var(--glass-border)' }}
>
<Link to="/">
<img
src="/static/officer-logo.svg"
alt="Officer"
className="h-8 md:h-12 w-auto"
style={{ transform: 'skew(-15deg, -2deg)' }}
/>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-duck-yellow cursor-pointer">
<Avatar className="h-9 w-9 rounded-full">
<AvatarImage src={user?.avatar ?? undefined} />
<AvatarFallback className="bg-duck-teal text-duck-yellow text-sm font-bold rounded-full">
{user?.name?.charAt(0).toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[600] w-48">
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile">
<User className="mr-2 h-4 w-4" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" />
AI Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/server">
<Server className="mr-2 h-4 w-4" />
Server Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/resources">
<Package className="mr-2 h-4 w-4" />
Resources
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
<div className={`flex-1 min-h-0 ${mobileFull ? 'pb-0 md:pb-20' : 'pb-16 md:pb-20'}`}>
{/* {isProduction && passkeyCount === 0 ? <PasskeyGate /> : children} */}
{children}
</div>
<Dock items={dockItems} className={mobileFull ? 'hidden md:flex' : 'flex'} />
</div>
</section>
</div>
);
}
@@ -12,7 +12,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<section className="relative h-dvh snap-start overflow-hidden">
<Background />
<Dock items={dockItems} />
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-[80px] overflow-y-auto">
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
{children}
</div>
</section>
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router';
import type { LucideIcon } from 'lucide-react';
@@ -19,6 +19,7 @@ const ICON_GAP = 24;
const DOCK_PADDING = 12;
const MAX_SCALE = 1.5;
const MAX_DISTANCE = 150;
const BOTTOM_THRESHOLD = 100;
const getScale = (mouseX: number | null, iconCenterX: number) => {
if (mouseX === null) return 1;
@@ -29,25 +30,39 @@ const getScale = (mouseX: number | null, iconCenterX: number) => {
export const Dock = ({ items, className }: DockProps) => {
const [mouseX, setMouseX] = useState<number | null>(null);
const [visible, setVisible] = useState(false);
const dockRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const isActive = (to: string) => location.pathname.startsWith(to);
const handleMouseMove = (ev: React.MouseEvent) => {
const rect = dockRef.current?.getBoundingClientRect();
if (rect) setMouseX(ev.clientX - rect.left);
};
useEffect(() => {
const handleMouseMove = (ev: MouseEvent) => {
const distFromBottom = window.innerHeight - ev.clientY;
if (distFromBottom <= BOTTOM_THRESHOLD) {
setVisible(true);
const rect = dockRef.current?.getBoundingClientRect();
if (rect) setMouseX(ev.clientX - rect.left);
} else {
setVisible(false);
setMouseX(null);
}
};
const handleMouseLeave = () => setMouseX(null);
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, []);
return (
<div
ref={dockRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className={`fixed bottom-4 left-1/2 -translate-x-1/2 z-5 items-end gap-2 md:gap-6 px-2 py-1.5 md:px-3 md:py-2 rounded-2xl border backdrop-blur-xl shadow-lg ${className ?? 'flex'}`}
style={{ backgroundColor: 'transparent', borderColor: 'transparent' }}
className={`fixed left-1/2 z-5 items-end gap-2 md:gap-6 px-2 py-1.5 md:px-3 md:py-2 rounded-2xl border backdrop-blur-xl shadow-lg transition-transform duration-300 ease-in-out ${className ?? 'flex'}`}
style={{
backgroundColor: 'var(--dock-bg)',
borderColor: 'var(--dock-border)',
bottom: '16px',
transform: `translateX(-50%) translateY(${visible ? '0' : 'calc(100% + 24px)'})`,
}}
>
{items.map((item, index) => {
const iconCenter = DOCK_PADDING + index * (ICON_SIZE + ICON_GAP) + ICON_SIZE / 2;
@@ -74,11 +89,11 @@ export const Dock = ({ items, className }: DockProps) => {
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
style={{
background: active ? `${item.color}55` : `${item.color}30`,
boxShadow: active ? `0 0 12px ${item.color}30` : 'none',
background: item.color,
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
}}
>
<item.icon className="h-5 w-5 md:h-6 md:w-6" style={{ color: active ? item.color : `${item.color}cc` }} />
<item.icon className="h-5 w-5 md:h-6 md:w-6 text-white" />
</div>
{active && (
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
@@ -1,5 +1,5 @@
import { Link } from 'react-router';
import { LayoutGrid, ArrowRight } from 'lucide-react';
import { LayoutGrid, Plus } from 'lucide-react';
import { useUserState } from '@/state/useUserState';
import type { WorkspaceDefinition } from '@/components/Workspace';
@@ -7,33 +7,35 @@ export const WorkspaceListApp = () => {
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
return (
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{workspaces.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-6 text-duck-dark/40">
<LayoutGrid className="h-6 w-6" />
<p className="text-xs">No workspaces</p>
<Link to="/workspaces" className="text-xs text-duck-teal hover:underline">
Create one
</Link>
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-0">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<LayoutGrid className="h-4 w-4" />
Workspaces
</div>
) : (
<ul className="space-y-0.5">
{workspaces.map((ws) => (
<li key={ws.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
<Link to={`/workspaces/${ws.id}`} className="flex items-center gap-2 flex-1 min-w-0">
<LayoutGrid className="h-4 w-4 shrink-0 text-duck-teal/60" />
<span className="text-sm text-duck-dark truncate">{ws.name}</span>
</Link>
<Link
to={`/workspaces/${ws.id}`}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity"
>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</li>
))}
</ul>
)}
</div>
<div className="flex flex-col gap-0.5 px-3 pt-3">
{workspaces.map((ws) => (
<Link
key={ws.id}
to={`/workspaces/${ws.id}`}
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/60 hover:bg-duck-dark/5 hover:text-duck-dark transition-all"
>
<LayoutGrid className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">{ws.name}</span>
</Link>
))}
{workspaces.length === 0 && (
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">No workspaces yet</p>
)}
<Link
to="/workspaces"
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all"
>
<Plus className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left">New workspace</span>
</Link>
</div>
</div>
);
};
+12 -12
View File
@@ -7,7 +7,7 @@ import { Plus, X } from 'lucide-react';
import { widgetRegistry } from '../widget-registry';
import { WidgetPicker } from './WidgetPicker';
type Position = { x: number; y: number };
type Position = { x: number; y: number }; // percentages (0100)
type WidgetInstance = {
id: string;
@@ -69,8 +69,8 @@ type DragState = {
startY: number;
originX: number;
originY: number;
widgetW: number;
widgetH: number;
widgetWPct: number;
widgetHPct: number;
panelW: number;
panelH: number;
};
@@ -91,8 +91,8 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
startY: ev.clientY,
originX: instance.position.x,
originY: instance.position.y,
widgetW: widgetRect.width,
widgetH: widgetRect.height,
widgetWPct: (widgetRect.width / panelRect.width) * 100,
widgetHPct: (widgetRect.height / panelRect.height) * 100,
panelW: panelRect.width,
panelH: panelRect.height,
};
@@ -119,11 +119,11 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
return;
}
const d = dragRef.current;
const rawX = d.originX + (ev.clientX - d.startX);
const rawY = d.originY + (ev.clientY - d.startY);
const deltaXPct = ((ev.clientX - d.startX) / d.panelW) * 100;
const deltaYPct = ((ev.clientY - d.startY) / d.panelH) * 100;
onMove({
x: Math.max(0, Math.min(rawX, d.panelW - d.widgetW)),
y: Math.max(0, Math.min(rawY, d.panelH - d.widgetH)),
x: Math.max(0, Math.min(d.originX + deltaXPct, 100 - d.widgetWPct)),
y: Math.max(0, Math.min(d.originY + deltaYPct, 100 - d.widgetHPct)),
});
},
[onMove, panelRef, endDrag],
@@ -144,7 +144,7 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
<div
ref={containerRef}
className="absolute group"
style={{ left: instance.position.x, top: instance.position.y }}
style={{ left: `${instance.position.x}%`, top: `${instance.position.y}%` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -194,8 +194,8 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
const addWidget = useCallback((widgetType: string) => {
nextId.current++;
const id = `widget-${nextId.current}`;
const offset = (nextId.current % 5) * 30;
setInstances((prev) => [...prev, { id, widgetType, position: { x: 20 + offset, y: 20 + offset } }]);
const offset = (nextId.current % 5) * 5;
setInstances((prev) => [...prev, { id, widgetType, position: { x: 2 + offset, y: 2 + offset } }]);
setShowPicker(false);
}, []);
@@ -1,54 +0,0 @@
import { Link } from 'react-router';
import { LayoutGrid, ArrowRight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { WorkspaceDefinition } from '@/components/Workspace';
import { Widget } from '../Widget';
export const Workspaces = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: state = {} } = useQuery<Record<string, unknown>>({
queryKey: ['USER_STATE'],
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const workspaces = (state.workspaces ?? []) as WorkspaceDefinition[];
return (
<Widget title="Workspaces">
<div className="px-2 pb-3 max-h-52 overflow-y-auto">
{workspaces.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-4 text-muted-foreground">
<LayoutGrid className="h-5 w-5" />
<p className="text-xs">No workspaces</p>
<Link to="/workspaces" className="text-xs text-primary hover:underline">
Create one
</Link>
</div>
) : (
<ul className="space-y-0.5">
{workspaces.map((ws) => (
<li key={ws.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-muted/50 group">
<Link to={`/workspaces/${ws.id}`} className="flex items-center gap-2 flex-1 min-w-0">
<LayoutGrid className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{ws.name}</span>
</Link>
<Link
to={`/workspaces/${ws.id}`}
className="shrink-0 p-1 rounded text-muted-foreground/40 md:opacity-0 md:group-hover:opacity-100 hover:text-primary transition-opacity"
>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</li>
))}
</ul>
)}
</div>
</Widget>
);
};
+1 -3
View File
@@ -1,9 +1,7 @@
import { Clock as ClockIcon, LayoutGrid } from 'lucide-react';
import { Clock as ClockIcon } from 'lucide-react';
import type { AppRegistryEntry } from '@/components/Workspace';
import { Clock } from './Clock/index';
import { Workspaces } from './Workspaces/index';
export const widgetRegistry: Record<string, AppRegistryEntry> = {
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock /> },
'workspaces': { name: 'Workspaces', icon: LayoutGrid, component: () => <Workspaces /> },
};