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

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

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

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