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:
46
apps/web/src/components/dashboard/DomainOverviewWidget.tsx
Normal file
46
apps/web/src/components/dashboard/DomainOverviewWidget.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
|
||||
interface DomainOverviewWidgetProps {
|
||||
tasks: Task[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function DomainOverviewWidget({ tasks, isLoading }: DomainOverviewWidgetProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading overview...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
||||
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
||||
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
|
||||
};
|
||||
|
||||
const StatCard = ({ label, value, color }: { label: string; value: number; color: string }) => (
|
||||
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
|
||||
<div className="text-3xl font-bold text-white mb-1">{value}</div>
|
||||
<div className="text-sm text-white/90">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Domain Overview</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard label="Total Tasks" value={stats.total} color="from-blue-500 to-blue-600" />
|
||||
<StatCard label="In Progress" value={stats.inProgress} color="from-green-500 to-green-600" />
|
||||
<StatCard label="Completed" value={stats.completed} color="from-purple-500 to-purple-600" />
|
||||
<StatCard label="High Priority" value={stats.highPriority} color="from-red-500 to-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/components/dashboard/QuickCaptureWidget.tsx
Normal file
55
apps/web/src/components/dashboard/QuickCaptureWidget.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@mosaic/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function QuickCaptureWidget() {
|
||||
const [idea, setIdea] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!idea.trim()) return;
|
||||
|
||||
// TODO: Implement quick capture API call
|
||||
// For now, just show a success indicator
|
||||
console.log("Quick capture:", idea);
|
||||
setIdea("");
|
||||
};
|
||||
|
||||
const goToTasks = () => {
|
||||
router.push("/tasks");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Capture</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Quickly jot down ideas or brain dumps
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<textarea
|
||||
value={idea}
|
||||
onChange={(e) => setIdea(e.target.value)}
|
||||
placeholder="What's on your mind?"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" variant="primary" size="sm">
|
||||
Save Note
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={goToTasks}
|
||||
>
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/components/dashboard/RecentTasksWidget.tsx
Normal file
86
apps/web/src/components/dashboard/RecentTasksWidget.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskPriority } from "@mosaic/shared";
|
||||
import { formatDate } from "@/lib/utils/date-format";
|
||||
import { TaskStatus } from "@mosaic/shared";
|
||||
import Link from "next/link";
|
||||
|
||||
interface RecentTasksWidgetProps {
|
||||
tasks: Task[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const statusIcons: Record<TaskStatus, string> = {
|
||||
[TaskStatus.NOT_STARTED]: "⚪",
|
||||
[TaskStatus.IN_PROGRESS]: "🟢",
|
||||
[TaskStatus.PAUSED]: "⏸️",
|
||||
[TaskStatus.COMPLETED]: "✅",
|
||||
[TaskStatus.ARCHIVED]: "💤",
|
||||
};
|
||||
|
||||
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading tasks...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const recentTasks = tasks.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Tasks</h2>
|
||||
<Link
|
||||
href="/tasks"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{recentTasks.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No tasks yet</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{recentTasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="text-lg flex-shrink-0" aria-label={`Status: ${task.status}`}>
|
||||
{statusIcons[task.status]}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 text-sm truncate">
|
||||
{task.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{task.priority !== TaskPriority.LOW && (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
task.priority === TaskPriority.HIGH
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
}`}
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(task.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/dashboard/UpcomingEventsWidget.tsx
Normal file
66
apps/web/src/components/dashboard/UpcomingEventsWidget.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { formatTime, formatDate } from "@/lib/utils/date-format";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UpcomingEventsWidgetProps {
|
||||
events: Event[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function UpcomingEventsWidget({ events, isLoading }: UpcomingEventsWidgetProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading events...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const upcomingEvents = events.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
|
||||
<Link
|
||||
href="/calendar"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
View calendar →
|
||||
</Link>
|
||||
</div>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No upcoming events</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border-l-4 border-blue-500 bg-gray-50"
|
||||
>
|
||||
<div className="flex-shrink-0 text-center min-w-[3.5rem]">
|
||||
<div className="text-xs text-gray-500 uppercase font-semibold">
|
||||
{formatDate(event.startTime).split(',')[0]}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatTime(event.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 text-sm truncate">
|
||||
{event.title}
|
||||
</h3>
|
||||
{event.location && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">📍 {event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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";
|
||||
@@ -10,6 +10,7 @@ export function Navigation() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard" },
|
||||
{ href: "/tasks", label: "Tasks" },
|
||||
{ href: "/calendar", label: "Calendar" },
|
||||
];
|
||||
@@ -19,7 +20,7 @@ export function Navigation() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/tasks" className="text-xl font-bold text-gray-900">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900">
|
||||
Mosaic Stack
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
|
||||
169
apps/web/src/components/widgets/AgentStatusWidget.tsx
Normal file
169
apps/web/src/components/widgets/AgentStatusWidget.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Agent Status Widget - shows running agents
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "IDLE" | "WORKING" | "WAITING" | "ERROR" | "TERMINATED";
|
||||
currentTask?: string;
|
||||
lastHeartbeat: string;
|
||||
taskCount: number;
|
||||
}
|
||||
|
||||
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Mock data for now - will fetch from API later
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setAgents([
|
||||
{
|
||||
id: "1",
|
||||
name: "Code Review Agent",
|
||||
status: "WORKING",
|
||||
currentTask: "Reviewing PR #123",
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
taskCount: 42,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Documentation Agent",
|
||||
status: "IDLE",
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
taskCount: 15,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Test Runner Agent",
|
||||
status: "ERROR",
|
||||
currentTask: "Failed to run tests",
|
||||
lastHeartbeat: new Date(Date.now() - 300000).toISOString(),
|
||||
taskCount: 28,
|
||||
},
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = (status: Agent["status"]) => {
|
||||
switch (status) {
|
||||
case "WORKING":
|
||||
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||
case "IDLE":
|
||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||
case "WAITING":
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
case "ERROR":
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
case "TERMINATED":
|
||||
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: Agent["status"]) => {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
const getTimeSinceLastHeartbeat = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const last = new Date(timestamp);
|
||||
const diffMs = now.getTime() - last.getTime();
|
||||
|
||||
if (diffMs < 60000) return "Just now";
|
||||
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
|
||||
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
|
||||
return `${Math.floor(diffMs / 86400000)}d ago`;
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: agents.length,
|
||||
working: agents.filter((a) => a.status === "WORKING").length,
|
||||
idle: agents.filter((a) => a.status === "IDLE").length,
|
||||
error: agents.filter((a) => a.status === "ERROR").length,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500 text-sm">Loading agents...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-4 gap-1 text-center text-xs">
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{stats.total}</div>
|
||||
<div className="text-gray-500">Total</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded p-2">
|
||||
<div className="text-lg font-bold text-blue-600">{stats.working}</div>
|
||||
<div className="text-blue-500">Working</div>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded p-2">
|
||||
<div className="text-lg font-bold text-gray-600">{stats.idle}</div>
|
||||
<div className="text-gray-500">Idle</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded p-2">
|
||||
<div className="text-lg font-bold text-red-600">{stats.error}</div>
|
||||
<div className="text-red-500">Error</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent list */}
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{agents.length === 0 ? (
|
||||
<div className="text-center text-gray-500 text-sm py-4">
|
||||
No agents configured
|
||||
</div>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className={`p-3 rounded-lg border ${
|
||||
agent.status === "ERROR"
|
||||
? "bg-red-50 border-red-200"
|
||||
: agent.status === "WORKING"
|
||||
? "bg-blue-50 border-blue-200"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
{getStatusIcon(agent.status)}
|
||||
<span>{getStatusText(agent.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.currentTask && (
|
||||
<div className="text-xs text-gray-600 mb-1">{agent.currentTask}</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||
<span>{agent.taskCount} tasks completed</span>
|
||||
<span>{getTimeSinceLastHeartbeat(agent.lastHeartbeat)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/components/widgets/CalendarWidget.tsx
Normal file
141
apps/web/src/components/widgets/CalendarWidget.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Calendar Widget - displays upcoming events
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
location?: string;
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
export function CalendarWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Mock data for now - will fetch from API later
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
setEvents([
|
||||
{
|
||||
id: "1",
|
||||
title: "Team Standup",
|
||||
startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(),
|
||||
endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(),
|
||||
location: "Zoom",
|
||||
allDay: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Project Review",
|
||||
startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(),
|
||||
endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(),
|
||||
location: "Conference Room A",
|
||||
allDay: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Sprint Planning",
|
||||
startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(),
|
||||
endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(),
|
||||
allDay: false,
|
||||
},
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDay = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return "Today";
|
||||
} else if (date.toDateString() === tomorrow.toDateString()) {
|
||||
return "Tomorrow";
|
||||
}
|
||||
return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const getUpcomingEvents = () => {
|
||||
const now = new Date();
|
||||
return events
|
||||
.filter((e) => new Date(e.startTime) > now)
|
||||
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
|
||||
.slice(0, 5);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500 text-sm">Loading events...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const upcomingEvents = getUpcomingEvents();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Upcoming Events</span>
|
||||
</div>
|
||||
|
||||
{/* Event list */}
|
||||
<div className="flex-1 overflow-auto space-y-3">
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<div className="text-center text-gray-500 text-sm py-4">No upcoming events</div>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<div key={event.id} className="border-l-2 border-blue-500 pl-3 py-1">
|
||||
<div className="text-sm font-medium text-gray-900">{event.title}</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||
{!event.allDay && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{formatTime(event.startTime)}
|
||||
{event.endTime && ` - ${formatTime(event.endTime)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{event.location && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-gray-400">{formatDay(event.startTime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/components/widgets/QuickCaptureWidget.tsx
Normal file
94
apps/web/src/components/widgets/QuickCaptureWidget.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Quick Capture Widget - idea/brain dump input
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Send, Lightbulb } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
export function QuickCaptureWidget({ id: _id, config: _config }: WidgetProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [recentCaptures, setRecentCaptures] = useState<string[]>([]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const idea = input.trim();
|
||||
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
// await api.ideas.create({ content: idea });
|
||||
|
||||
// Add to recent captures for visual feedback
|
||||
setRecentCaptures((prev) => [idea, ...prev].slice(0, 3));
|
||||
setInput("");
|
||||
} catch (error) {
|
||||
console.error("Failed to capture idea:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<Lightbulb className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-sm font-medium">Quick Capture</span>
|
||||
</div>
|
||||
|
||||
{/* Input form */}
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Capture an idea..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isSubmitting}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Recent captures */}
|
||||
{recentCaptures.length > 0 && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="text-xs text-gray-500 mb-2">Recently captured:</div>
|
||||
<div className="space-y-2">
|
||||
{recentCaptures.map((capture, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-2 bg-gray-50 rounded text-sm text-gray-700"
|
||||
>
|
||||
{capture}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
{recentCaptures.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-400 text-xs space-y-1">
|
||||
<div>Capture ideas quickly</div>
|
||||
<div>They'll be organized later</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
apps/web/src/components/widgets/TasksWidget.tsx
Normal file
134
apps/web/src/components/widgets/TasksWidget.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Tasks Widget - displays task summary and list
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
export function TasksWidget({ }: WidgetProps) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Mock data for now - will fetch from API later
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setTasks([
|
||||
{
|
||||
id: "1",
|
||||
title: "Complete project documentation",
|
||||
status: "IN_PROGRESS",
|
||||
priority: "HIGH",
|
||||
dueDate: "2024-02-01",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Review pull requests",
|
||||
status: "NOT_STARTED",
|
||||
priority: "MEDIUM",
|
||||
dueDate: "2024-02-02",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Update dependencies",
|
||||
status: "COMPLETED",
|
||||
priority: "LOW",
|
||||
dueDate: "2024-01-30",
|
||||
},
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "HIGH":
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
case "MEDIUM":
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
case "LOW":
|
||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||
default:
|
||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
return status === "COMPLETED" ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-gray-400" />
|
||||
);
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
|
||||
completed: tasks.filter((t) => t.status === "COMPLETED").length,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500 text-sm">Loading tasks...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded p-2">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.inProgress}</div>
|
||||
<div className="text-xs text-blue-500">In Progress</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded p-2">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
|
||||
<div className="text-xs text-green-500">Done</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center text-gray-500 text-sm py-4">No tasks yet</div>
|
||||
) : (
|
||||
tasks.slice(0, 5).map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded transition-colors"
|
||||
>
|
||||
{getStatusIcon(task.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{task.title}
|
||||
</div>
|
||||
{task.dueDate && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Due: {new Date(task.dueDate).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{getPriorityIcon(task.priority)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
apps/web/src/components/widgets/index.ts
Normal file
8
apps/web/src/components/widgets/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Widget components
|
||||
*/
|
||||
|
||||
export { TasksWidget } from "./TasksWidget";
|
||||
export { CalendarWidget } from "./CalendarWidget";
|
||||
export { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||
export { AgentStatusWidget } from "./AgentStatusWidget";
|
||||
22
apps/web/src/components/widgets/widget-registry.ts
Normal file
22
apps/web/src/components/widgets/widget-registry.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Widget component registry - React-specific definitions
|
||||
*/
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
/**
|
||||
* Widget component registry
|
||||
*/
|
||||
export interface WidgetComponent {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
component: ComponentType<WidgetProps>;
|
||||
defaultWidth: number;
|
||||
defaultHeight: number;
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user