feat: add domains, ideas, layouts, widgets API modules
- Add DomainsModule with full CRUD, search, and activity logging - Add IdeasModule with quick capture endpoint - Add LayoutsModule for user dashboard layouts - Add WidgetsModule for widget definitions (read-only) - Update ActivityService with domain/idea logging methods - Register all new modules in AppModule
This commit is contained in:
182
apps/web/src/components/hud/HUD.tsx
Normal file
182
apps/web/src/components/hud/HUD.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* HUD container - main dashboard interface
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@mosaic/ui";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { WidgetGrid } from "./WidgetGrid";
|
||||
import { WidgetRenderer } from "./WidgetRenderer";
|
||||
import { useLayout } from "@/lib/hooks/useLayout";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
export interface HUDProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of available widget components
|
||||
* This will be populated with actual widget components
|
||||
*/
|
||||
const WIDGET_REGISTRY = {
|
||||
TasksWidget: {
|
||||
name: "tasks",
|
||||
displayName: "Tasks",
|
||||
description: "View and manage your tasks",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 3,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
},
|
||||
CalendarWidget: {
|
||||
name: "calendar",
|
||||
displayName: "Calendar",
|
||||
description: "Upcoming events and schedule",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
},
|
||||
QuickCaptureWidget: {
|
||||
name: "quick-capture",
|
||||
displayName: "Quick Capture",
|
||||
description: "Capture ideas and notes",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 1,
|
||||
minWidth: 1,
|
||||
minHeight: 1,
|
||||
},
|
||||
AgentStatusWidget: {
|
||||
name: "agent-status",
|
||||
displayName: "Agent Status",
|
||||
description: "View running agent sessions",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
||||
|
||||
export function HUD({ className = "" }: HUDProps) {
|
||||
const {
|
||||
currentLayout,
|
||||
updateLayout,
|
||||
addWidget,
|
||||
removeWidget,
|
||||
switchLayout,
|
||||
resetLayout,
|
||||
} = useLayout();
|
||||
|
||||
const isEditing = true; // For now, always in edit mode (can be toggled later)
|
||||
|
||||
const handleLayoutChange = (newLayout: readonly { i: string; x: number; y: number; w: number; h: number }[]) => {
|
||||
updateLayout([...newLayout] as WidgetPlacement[]);
|
||||
};
|
||||
|
||||
const handleAddWidget = (widgetType: WidgetRegistryKey) => {
|
||||
const widgetConfig = WIDGET_REGISTRY[widgetType];
|
||||
const widgetId = `${widgetType.toLowerCase()}-${Date.now()}`;
|
||||
|
||||
// Find the next available position
|
||||
const maxY = currentLayout?.layout.reduce((max, w) => Math.max(max, w.y + w.h), 0) || 0;
|
||||
|
||||
const newWidget = {
|
||||
i: widgetId,
|
||||
x: 0,
|
||||
y: maxY,
|
||||
w: widgetConfig.defaultWidth,
|
||||
h: widgetConfig.defaultHeight,
|
||||
minW: widgetConfig.minWidth,
|
||||
minH: widgetConfig.minHeight,
|
||||
isDraggable: true,
|
||||
isResizable: true,
|
||||
};
|
||||
|
||||
addWidget(newWidget);
|
||||
};
|
||||
|
||||
const handleResetLayout = () => {
|
||||
if (confirm("Are you sure you want to reset the layout? This will remove all widgets.")) {
|
||||
resetLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const widgetComponents = useMemo(() => {
|
||||
if (!currentLayout?.layout) return [];
|
||||
|
||||
return currentLayout.layout.map((widget) => (
|
||||
<WidgetRenderer
|
||||
key={widget.i}
|
||||
widget={widget}
|
||||
isEditing={isEditing}
|
||||
onRemove={removeWidget}
|
||||
/>
|
||||
));
|
||||
}, [currentLayout?.layout, isEditing, removeWidget]);
|
||||
|
||||
return (
|
||||
<div className={`hud-container ${className}`}>
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={currentLayout?.id || ""}
|
||||
onChange={(e) => switchLayout(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Default Layout</option>
|
||||
{/* Add more layout options here */}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
onClick={handleResetLayout}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Widget type selector */}
|
||||
<div className="relative">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const widgetType = e.target.value as WidgetRegistryKey;
|
||||
if (widgetType) {
|
||||
handleAddWidget(widgetType);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Add Widget
|
||||
</option>
|
||||
{Object.entries(WIDGET_REGISTRY).map(([key, config]) => (
|
||||
<option key={key} value={key}>
|
||||
{config.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Widget Grid */}
|
||||
<WidgetGrid
|
||||
layout={currentLayout?.layout || []}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
isEditing={isEditing}
|
||||
>
|
||||
{widgetComponents}
|
||||
</WidgetGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/components/hud/WidgetGrid.tsx
Normal file
117
apps/web/src/components/hud/WidgetGrid.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Widget grid container using react-grid-layout
|
||||
*/
|
||||
|
||||
import { Responsive as ResponsiveGridLayout, useContainerWidth } from "react-grid-layout";
|
||||
import type { ReactNode } from "react";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
// Import CSS for react-grid-layout
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import "react-resizable/css/styles.css";
|
||||
|
||||
export interface WidgetGridProps {
|
||||
children: ReactNode[];
|
||||
layout: WidgetPlacement[];
|
||||
onLayoutChange?: (layout: readonly WidgetPlacement[]) => void;
|
||||
isEditing?: boolean;
|
||||
breakpoints?: { [key: string]: number };
|
||||
cols?: { [key: string]: number };
|
||||
rowHeight?: number;
|
||||
margin?: [number, number];
|
||||
containerPadding?: [number, number];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_BREAKPOINTS = {
|
||||
lg: 1200,
|
||||
md: 996,
|
||||
sm: 768,
|
||||
xs: 480,
|
||||
xxs: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_COLS = {
|
||||
lg: 4,
|
||||
md: 3,
|
||||
sm: 2,
|
||||
xs: 1,
|
||||
xxs: 1,
|
||||
};
|
||||
|
||||
export function WidgetGrid({
|
||||
children,
|
||||
layout,
|
||||
onLayoutChange,
|
||||
isEditing = false,
|
||||
breakpoints = DEFAULT_BREAKPOINTS,
|
||||
cols = DEFAULT_COLS,
|
||||
rowHeight = 100,
|
||||
margin = [16, 16],
|
||||
containerPadding = [16, 16],
|
||||
className = "",
|
||||
}: WidgetGridProps) {
|
||||
// Use hook to measure container width
|
||||
const { width, containerRef, mounted } = useContainerWidth({ measureBeforeMount: true });
|
||||
// Convert our WidgetPlacement to react-grid-layout's Layout format
|
||||
const rglLayout = layout.map((widget) => {
|
||||
const layoutItem: {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW: number;
|
||||
maxW?: number;
|
||||
minH: number;
|
||||
maxH?: number;
|
||||
static?: boolean;
|
||||
isDraggable?: boolean;
|
||||
isResizable?: boolean;
|
||||
} = {
|
||||
i: widget.i,
|
||||
x: widget.x,
|
||||
y: widget.y,
|
||||
w: widget.w,
|
||||
h: widget.h,
|
||||
minW: widget.minW || 1,
|
||||
minH: widget.minH || 1,
|
||||
};
|
||||
|
||||
if (widget.maxW !== undefined) layoutItem.maxW = widget.maxW;
|
||||
if (widget.maxH !== undefined) layoutItem.maxH = widget.maxH;
|
||||
if (widget.static) layoutItem.static = true;
|
||||
if (isEditing && widget.isDraggable !== false) layoutItem.isDraggable = true;
|
||||
if (isEditing && widget.isResizable !== false) layoutItem.isResizable = true;
|
||||
|
||||
return layoutItem;
|
||||
});
|
||||
|
||||
const handleLayoutChange = (layout: readonly any[]) => {
|
||||
if (onLayoutChange) {
|
||||
onLayoutChange([...layout] as WidgetPlacement[]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: "100%" }}>
|
||||
{mounted && (
|
||||
<ResponsiveGridLayout
|
||||
className={`widget-grid ${className}`}
|
||||
layouts={{ lg: rglLayout }}
|
||||
breakpoints={breakpoints}
|
||||
cols={cols}
|
||||
rowHeight={rowHeight}
|
||||
margin={margin}
|
||||
containerPadding={containerPadding}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
width={width}
|
||||
>
|
||||
{children.map((child, index) => (
|
||||
<div key={layout[index]?.i || index}>{child}</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
apps/web/src/components/hud/WidgetRenderer.tsx
Normal file
74
apps/web/src/components/hud/WidgetRenderer.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Widget renderer - renders the appropriate widget component based on type
|
||||
*/
|
||||
|
||||
import { WidgetWrapper } from "./WidgetWrapper";
|
||||
import { TasksWidget, CalendarWidget, QuickCaptureWidget, AgentStatusWidget } from "@/components/widgets";
|
||||
import type { WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
export interface WidgetRendererProps {
|
||||
widget: WidgetPlacement;
|
||||
isEditing?: boolean;
|
||||
onRemove?: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
const WIDGET_COMPONENTS = {
|
||||
tasks: TasksWidget,
|
||||
calendar: CalendarWidget,
|
||||
"quick-capture": QuickCaptureWidget,
|
||||
"agent-status": AgentStatusWidget,
|
||||
};
|
||||
|
||||
const WIDGET_CONFIG = {
|
||||
tasks: {
|
||||
displayName: "Tasks",
|
||||
description: "View and manage your tasks",
|
||||
},
|
||||
calendar: {
|
||||
displayName: "Calendar",
|
||||
description: "Upcoming events and schedule",
|
||||
},
|
||||
"quick-capture": {
|
||||
displayName: "Quick Capture",
|
||||
description: "Capture ideas and notes",
|
||||
},
|
||||
"agent-status": {
|
||||
displayName: "Agent Status",
|
||||
description: "View running agent sessions",
|
||||
},
|
||||
};
|
||||
|
||||
export function WidgetRenderer({ widget, isEditing = false, onRemove }: WidgetRendererProps) {
|
||||
// Extract widget type from ID (e.g., "tasks-123" -> "tasks")
|
||||
const widgetType = widget.i.split("-")[0] as keyof typeof WIDGET_COMPONENTS;
|
||||
const WidgetComponent = WIDGET_COMPONENTS[widgetType];
|
||||
const config = WIDGET_CONFIG[widgetType] || { displayName: "Widget", description: "" };
|
||||
|
||||
if (!WidgetComponent) {
|
||||
const wrapperProps = {
|
||||
id: widget.i,
|
||||
title: "Unknown Widget",
|
||||
isEditing: isEditing,
|
||||
...(onRemove && { onRemove: () => onRemove(widget.i) }),
|
||||
};
|
||||
|
||||
return (
|
||||
<WidgetWrapper {...wrapperProps}>
|
||||
<div className="text-gray-500 text-sm">Widget type not found: {widgetType}</div>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const wrapperProps = {
|
||||
id: widget.i,
|
||||
title: config.displayName,
|
||||
isEditing: isEditing,
|
||||
...(onRemove && { onRemove: () => onRemove(widget.i) }),
|
||||
};
|
||||
|
||||
return (
|
||||
<WidgetWrapper {...wrapperProps}>
|
||||
<WidgetComponent id={widget.i} />
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
109
apps/web/src/components/hud/WidgetWrapper.tsx
Normal file
109
apps/web/src/components/hud/WidgetWrapper.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Widget wrapper with drag/resize handles and edit controls
|
||||
*/
|
||||
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Card, CardHeader, CardContent } from "@mosaic/ui";
|
||||
import { GripVertical, Maximize2, Minimize2, X, Settings } from "lucide-react";
|
||||
|
||||
export interface WidgetWrapperProps {
|
||||
id: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
isEditing?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onEdit?: () => void;
|
||||
onRemove?: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WidgetWrapper({
|
||||
id,
|
||||
title,
|
||||
children,
|
||||
isEditing = false,
|
||||
isCollapsed = false,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggleCollapse,
|
||||
className = "",
|
||||
}: WidgetWrapperProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Card
|
||||
id={id}
|
||||
className={`relative flex flex-col h-full ${isCollapsed ? "min-h-[60px]" : ""} ${className}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{isEditing && (
|
||||
<div className="absolute top-2 left-2 z-10 cursor-grab active:cursor-grabbing p-1 rounded hover:bg-gray-100 group">
|
||||
<GripVertical className="w-4 h-4 text-gray-400 group-hover:text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<CardHeader
|
||||
className={`px-4 py-3 border-b border-gray-200 flex items-center justify-between ${
|
||||
isEditing ? "pr-20" : ""
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!isEditing && (isHovered || isCollapsed) && (
|
||||
<>
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleCollapse();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700"
|
||||
title={isCollapsed ? "Expand" : "Collapse"}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!isCollapsed && onEdit && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isEditing && onRemove && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-100 text-gray-500 hover:text-red-600"
|
||||
title="Remove widget"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Content */}
|
||||
{!isCollapsed && <CardContent className="flex-1 overflow-auto p-4">{children}</CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
12
apps/web/src/components/hud/index.ts
Normal file
12
apps/web/src/components/hud/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* HUD components
|
||||
*/
|
||||
|
||||
export { HUD } from "./HUD";
|
||||
export { WidgetGrid } from "./WidgetGrid";
|
||||
export { WidgetWrapper } from "./WidgetWrapper";
|
||||
export { WidgetRenderer } from "./WidgetRenderer";
|
||||
export type { WidgetWrapperProps } from "./WidgetWrapper";
|
||||
export type { WidgetGridProps } from "./WidgetGrid";
|
||||
export type { HUDProps } from "./HUD";
|
||||
export type { WidgetRendererProps } from "./WidgetRenderer";
|
||||
Reference in New Issue
Block a user