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

@@ -128,3 +128,6 @@ export * from "./database.types";
// Export authentication types
export * from "./auth.types";
// Export widget types
export * from "./widget.types";

View File

@@ -0,0 +1,81 @@
/**
* Widget and layout type definitions for HUD system
*/
import type { BaseEntity } from "./index";
/**
* Widget placement in the grid
*/
export interface WidgetPlacement {
i: string; // Widget ID
x: number; // Column position
y: number; // Row position
w: number; // Width in grid units
h: number; // Height in grid units
minW?: number;
maxW?: number;
minH?: number;
maxH?: number;
static?: boolean;
isDraggable?: boolean;
isResizable?: boolean;
}
/**
* Widget definition from database
*/
export interface WidgetDefinition extends BaseEntity {
name: string;
displayName: string;
description: string | null;
component: string;
defaultWidth: number;
defaultHeight: number;
minWidth: number;
minHeight: number;
maxWidth: number | null;
maxHeight: number | null;
configSchema: Record<string, unknown>;
isActive: boolean;
}
/**
* User layout configuration
*/
export interface UserLayout extends BaseEntity {
workspaceId: string;
userId: string;
name: string;
isDefault: boolean;
layout: WidgetPlacement[];
metadata: Record<string, unknown>;
}
/**
* Layout configuration for editor
*/
export interface LayoutConfig {
id: string;
name: string;
layout: WidgetPlacement[];
}
/**
* Available widget types (component names)
*/
export type WidgetComponentType =
| "TasksWidget"
| "CalendarWidget"
| "QuickCaptureWidget"
| "AgentStatusWidget";
/**
* Props for individual widgets
*/
export interface WidgetProps {
id: string;
config?: Record<string, unknown>;
onEdit?: () => void;
onRemove?: () => void;
}

View File

@@ -0,0 +1,67 @@
import type { ImgHTMLAttributes, ReactNode } from "react";
export interface AvatarProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "size"> {
src?: string;
alt?: string;
size?: "sm" | "md" | "lg" | "xl";
fallback?: ReactNode;
initials?: string;
}
export function Avatar({
src,
alt = "User avatar",
size = "md",
fallback,
initials,
className = "",
...props
}: AvatarProps) {
const sizeStyles = {
sm: "w-6 h-6 text-xs",
md: "w-8 h-8 text-sm",
lg: "w-12 h-12 text-base",
xl: "w-16 h-16 text-xl",
};
const baseStyles = "rounded-full overflow-hidden flex items-center justify-center bg-gray-200 font-medium text-gray-600";
const combinedClassName = [baseStyles, sizeStyles[size], className].filter(Boolean).join(" ");
if (src) {
return (
<img
src={src}
alt={alt}
className={`${combinedClassName} object-cover`}
{...props}
/>
);
}
if (fallback) {
return <div className={combinedClassName}>{fallback}</div>;
}
if (initials) {
return <div className={combinedClassName}>{initials}</div>;
}
// Default fallback with user icon
return (
<div className={combinedClassName}>
<svg
className="w-1/2 h-1/2 text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { HTMLAttributes, ReactNode } from "react";
export type BadgeVariant = "priority-high" | "priority-medium" | "priority-low" | "status-success" | "status-warning" | "status-error" | "status-info" | "status-neutral";
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
children: ReactNode;
}
const variantStyles: Record<BadgeVariant, string> = {
"priority-high": "bg-red-100 text-red-800 border-red-200",
"priority-medium": "bg-yellow-100 text-yellow-800 border-yellow-200",
"priority-low": "bg-green-100 text-green-800 border-green-200",
"status-success": "bg-green-100 text-green-800 border-green-200",
"status-warning": "bg-yellow-100 text-yellow-800 border-yellow-200",
"status-error": "bg-red-100 text-red-800 border-red-200",
"status-info": "bg-blue-100 text-blue-800 border-blue-200",
"status-neutral": "bg-gray-100 text-gray-800 border-gray-200",
};
export function Badge({ variant = "status-neutral", children, className = "", ...props }: BadgeProps) {
const baseStyles = "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border";
const combinedClassName = [baseStyles, variantStyles[variant], className].filter(Boolean).join(" ");
return (
<span className={combinedClassName} role="status" aria-label={children as string} {...props}>
{children}
</span>
);
}

View File

@@ -1,7 +1,7 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
children: ReactNode;
}
@@ -19,6 +19,7 @@ export function Button({
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700",
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border border-gray-300",
};
const sizeStyles = {

View File

@@ -0,0 +1,57 @@
import type { ReactNode } from "react";
export interface CardProps {
children: ReactNode;
className?: string;
id?: string;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
}
export interface CardHeaderProps {
children: ReactNode;
className?: string;
}
export interface CardContentProps {
children: ReactNode;
className?: string;
}
export interface CardFooterProps {
children: ReactNode;
className?: string;
}
export function Card({ children, className = "", id, onMouseEnter, onMouseLeave }: CardProps) {
return (
<div
id={id}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={`bg-white rounded-lg shadow-md border border-gray-200 ${className}`}
>
{children}
</div>
);
}
export function CardHeader({ children, className = "" }: CardHeaderProps) {
return (
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
{children}
</div>
);
}
export function CardContent({ children, className = "" }: CardContentProps) {
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
}
export function CardFooter({ children, className = "" }: CardFooterProps) {
return (
<div className={`px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg ${className}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import type { InputHTMLAttributes } from "react";
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
}
export function Input({
label,
error,
helperText,
fullWidth = false,
className = "",
id,
...props
}: InputProps) {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
const errorId = error ? `${inputId}-error` : undefined;
const helperId = helperText ? `${inputId}-helper` : undefined;
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
const widthStyles = fullWidth ? "w-full" : "";
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
const combinedClassName = [baseStyles, widthStyles, errorStyles, className].filter(Boolean).join(" ");
return (
<div className={fullWidth ? "w-full" : ""}>
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
</label>
)}
<input
id={inputId}
className={combinedClassName}
aria-invalid={error ? "true" : "false"}
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
{...props}
/>
{error && (
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={helperId} className="mt-1 text-sm text-gray-500">
{helperText}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, type ReactNode, type HTMLAttributes } from "react";
export interface ModalProps extends HTMLAttributes<HTMLDivElement> {
isOpen: boolean;
onClose: () => void;
title?: string;
footer?: ReactNode;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
size?: "sm" | "md" | "lg" | "xl" | "full";
}
export function Modal({
isOpen,
onClose,
title,
footer,
closeOnOverlayClick = true,
closeOnEscape = true,
size = "md",
children,
className = "",
...props
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const modalId = useRef(`modal-${Math.random().toString(36).substr(2, 9)}`);
const sizeStyles = {
sm: "max-w-md",
md: "max-w-lg",
lg: "max-w-2xl",
xl: "max-w-4xl",
full: "max-w-full mx-4",
};
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (closeOnEscape && event.key === "Escape" && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
// Focus the modal when opened
dialogRef.current?.focus();
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "";
};
}, [isOpen, closeOnEscape, onClose]);
if (!isOpen) {
return null;
}
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (closeOnOverlayClick && event.target === event.currentTarget) {
onClose();
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby={title ? `${modalId.current}-title` : undefined}
{...props}
>
<div
ref={dialogRef}
tabIndex={-1}
className={`bg-white rounded-lg shadow-xl w-full ${sizeStyles[size]} ${className}`}
role="document"
>
{title && (
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2
id={`${modalId.current}-title`}
className="text-lg font-semibold text-gray-900"
>
{title}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
aria-label="Close modal"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)}
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
{footer && (
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg flex justify-end gap-2">
{footer}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import type { SelectHTMLAttributes } from "react";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
options: SelectOption[];
placeholder?: string;
}
export function Select({
label,
error,
helperText,
fullWidth = false,
options,
placeholder = "Select an option...",
className = "",
id,
...props
}: SelectProps) {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
const errorId = error ? `${selectId}-error` : undefined;
const helperId = helperText ? `${selectId}-helper` : undefined;
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white";
const widthStyles = fullWidth ? "w-full" : "";
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
const combinedClassName = [baseStyles, widthStyles, errorStyles, className].filter(Boolean).join(" ");
return (
<div className={fullWidth ? "w-full" : ""}>
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
</label>
)}
<select
id={selectId}
className={combinedClassName}
aria-invalid={error ? "true" : "false"}
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
{...props}
>
<option value="" disabled>
{placeholder}
</option>
{options.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
{error && (
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={helperId} className="mt-1 text-sm text-gray-500">
{helperText}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import type { TextareaHTMLAttributes } from "react";
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
resize?: "none" | "both" | "horizontal" | "vertical";
}
export function Textarea({
label,
error,
helperText,
fullWidth = false,
resize = "vertical",
className = "",
id,
...props
}: TextareaProps) {
const textareaId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
const errorId = error ? `${textareaId}-error` : undefined;
const helperId = helperText ? `${textareaId}-helper` : undefined;
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
const widthStyles = fullWidth ? "w-full" : "";
const resizeStyles = {
none: "resize-none",
both: "resize",
horizontal: "resize-x",
vertical: "resize-y",
};
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
const combinedClassName = [
baseStyles,
widthStyles,
resizeStyles[resize],
errorStyles,
className,
].filter(Boolean).join(" ");
return (
<div className={fullWidth ? "w-full" : ""}>
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
</label>
)}
<textarea
id={textareaId}
className={combinedClassName}
aria-invalid={error ? "true" : "false"}
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
{...props}
/>
{error && (
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={helperId} className="mt-1 text-sm text-gray-500">
{helperText}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
type HTMLAttributes,
} from "react";
export type ToastVariant = "success" | "error" | "warning" | "info";
export interface Toast {
id: string;
message: string;
variant?: ToastVariant;
duration?: number;
}
export interface ToastContextValue {
showToast: (message: string, variant?: ToastVariant, duration?: number) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}
export interface ToastProviderProps {
children: ReactNode;
}
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback(
(message: string, variant: ToastVariant = "info", duration: number = 5000) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newToast: Toast = { id, message, variant, duration };
setToasts((prev) => [...prev, newToast]);
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
},
[removeToast]
);
return (
<ToastContext.Provider value={{ showToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
}
export interface ToastContainerProps extends HTMLAttributes<HTMLDivElement> {
toasts: Toast[];
onRemove: (id: string) => void;
}
function ToastContainer({ toasts, onRemove, className = "" }: ToastContainerProps) {
if (toasts.length === 0) return null;
return (
<div
className={`fixed bottom-4 right-4 z-50 flex flex-col gap-2 ${className}`}
role="alert"
aria-live="polite"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
))}
</div>
);
}
interface ToastItemProps {
toast: Toast;
onRemove: (id: string) => void;
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const variantStyles: Record<ToastVariant, string> = {
success: "bg-green-500 text-white border-green-600",
error: "bg-red-500 text-white border-red-600",
warning: "bg-yellow-500 text-white border-yellow-600",
info: "bg-blue-500 text-white border-blue-600",
};
const icon: Record<ToastVariant, ReactNode> = {
success: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
),
error: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
),
warning: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
),
info: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
};
return (
<div
className={`${variantStyles[toast.variant || "info"]} border rounded-md shadow-lg px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md`}
role="alert"
>
<span className="flex-shrink-0">{icon[toast.variant || "info"]}</span>
<span className="flex-1 text-sm font-medium">{toast.message}</span>
<button
onClick={() => onRemove(toast.id)}
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-white/20"
aria-label="Close notification"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
);
}
// Helper function to show toasts outside of React components
let toastContextValue: ToastContextValue | null = null;
export function setToastContext(context: ToastContextValue | null) {
toastContextValue = context;
}
export interface ToastOptions {
variant?: ToastVariant;
duration?: number;
}
export function toast(message: string, options?: ToastOptions) {
if (!toastContextValue) {
console.warn("Toast context not available. Make sure ToastProvider is mounted.");
return;
}
toastContextValue.showToast(
message,
options?.variant || "info",
options?.duration || 5000
);
}

View File

@@ -1,2 +1,35 @@
// Button
export { Button } from "./components/Button.js";
export type { ButtonProps } from "./components/Button.js";
// Card
export { Card, CardHeader, CardContent, CardFooter } from "./components/Card.js";
export type { CardProps, CardHeaderProps, CardContentProps, CardFooterProps } from "./components/Card.js";
// Badge
export { Badge } from "./components/Badge.js";
export type { BadgeProps, BadgeVariant } from "./components/Badge.js";
// Input
export { Input } from "./components/Input.js";
export type { InputProps } from "./components/Input.js";
// Textarea
export { Textarea } from "./components/Textarea.js";
export type { TextareaProps } from "./components/Textarea.js";
// Avatar
export { Avatar } from "./components/Avatar.js";
export type { AvatarProps } from "./components/Avatar.js";
// Select
export { Select } from "./components/Select.js";
export type { SelectProps, SelectOption } from "./components/Select.js";
// Modal
export { Modal } from "./components/Modal.js";
export type { ModalProps } from "./components/Modal.js";
// Toast
export { ToastProvider, useToast, toast } from "./components/Toast.js";
export type { Toast, ToastVariant, ToastContextValue, ToastProviderProps } from "./components/Toast.js";