Workspaces left panel in homepage
This commit is contained in:
@@ -6,10 +6,21 @@ import { appRegistry } from '../Workspaces/app-registry';
|
||||
const initialLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'home-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ 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-bottom', appType: 'chat-launcher' }, size: 40 },
|
||||
{ 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) => {
|
||||
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
|
||||
<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>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (0–100)
|
||||
|
||||
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,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 /> },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user