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:
Jason Woltje
2026-01-29 13:47:03 -06:00
parent 973502f26e
commit f47dd8bc92
66 changed files with 4277 additions and 29 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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";