=== NEW === - studio/ — MCPEngine Studio scaffold (Next.js monorepo, build plan) - docs/FACTORY-V2.md — Factory v2 architecture doc - docs/CALENDLY_MCP_BUILD_SUMMARY.md — Calendly MCP build report === UPDATED SERVERS === - fieldedge: Added jobs-tools, UI build script, main entry update - lightspeed: Updated main + server entry points - squarespace: Added collection-browser + page-manager apps - toast: Added main + server entry points === INFRA === - infra/command-center/state.json — Updated pipeline state - infra/command-center/FACTORY-V2.md — Factory v2 operator playbook
187 lines
5.8 KiB
TypeScript
187 lines
5.8 KiB
TypeScript
'use client';
|
|
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
import { clsx } from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
|
|
/* ---------- Types ---------- */
|
|
|
|
export type ToastVariant = 'success' | 'error' | 'info' | 'loading';
|
|
|
|
export interface ToastItem {
|
|
id: string;
|
|
variant: ToastVariant;
|
|
message: string;
|
|
duration?: number; // ms — 0 = manual dismiss
|
|
}
|
|
|
|
export interface ToastAPI {
|
|
toast: (variant: ToastVariant, message: string, duration?: number) => string;
|
|
dismiss: (id: string) => void;
|
|
dismissAll: () => void;
|
|
}
|
|
|
|
/* ---------- Context ---------- */
|
|
|
|
const ToastContext = createContext<ToastAPI | null>(null);
|
|
|
|
export function useToast(): ToastAPI {
|
|
const ctx = useContext(ToastContext);
|
|
if (!ctx) throw new Error('useToast must be used within <ToastProvider>');
|
|
return ctx;
|
|
}
|
|
|
|
/* ---------- Icons ---------- */
|
|
|
|
const CheckIcon: React.FC = () => (
|
|
<svg className="h-5 w-5 text-emerald-400 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
);
|
|
|
|
const ErrorIcon: React.FC = () => (
|
|
<svg className="h-5 w-5 text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
|
</svg>
|
|
);
|
|
|
|
const InfoIcon: React.FC = () => (
|
|
<svg className="h-5 w-5 text-blue-400 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
|
</svg>
|
|
);
|
|
|
|
const LoadingIcon: React.FC = () => (
|
|
<svg className="h-5 w-5 text-gray-400 animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
);
|
|
|
|
const icons: Record<ToastVariant, React.FC> = {
|
|
success: CheckIcon,
|
|
error: ErrorIcon,
|
|
info: InfoIcon,
|
|
loading: LoadingIcon,
|
|
};
|
|
|
|
const borderColors: Record<ToastVariant, string> = {
|
|
success: 'border-l-emerald-500',
|
|
error: 'border-l-red-500',
|
|
info: 'border-l-blue-500',
|
|
loading: 'border-l-gray-500',
|
|
};
|
|
|
|
const defaultDurations: Record<ToastVariant, number> = {
|
|
success: 5000,
|
|
error: 8000,
|
|
info: 5000,
|
|
loading: 0, // manual dismiss
|
|
};
|
|
|
|
/* ---------- Single Toast ---------- */
|
|
|
|
interface ToastCardProps {
|
|
item: ToastItem;
|
|
onDismiss: (id: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
const ToastCard: React.FC<ToastCardProps> = ({ item, onDismiss, className }) => {
|
|
const [exiting, setExiting] = useState(false);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const Icon = icons[item.variant];
|
|
const duration = item.duration ?? defaultDurations[item.variant];
|
|
|
|
useEffect(() => {
|
|
if (duration > 0) {
|
|
timerRef.current = setTimeout(() => {
|
|
setExiting(true);
|
|
setTimeout(() => onDismiss(item.id), 200);
|
|
}, duration);
|
|
}
|
|
return () => clearTimeout(timerRef.current);
|
|
}, [duration, item.id, onDismiss]);
|
|
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className={twMerge(
|
|
clsx(
|
|
'flex items-start gap-3 px-4 py-3 rounded-lg border-l-4',
|
|
'bg-gray-900 border border-gray-700 shadow-lg shadow-black/30',
|
|
'transition-all duration-200 ease-out',
|
|
borderColors[item.variant],
|
|
exiting
|
|
? 'opacity-0 translate-x-4'
|
|
: 'opacity-100 translate-x-0 animate-[slideInRight_0.25s_ease-out]',
|
|
),
|
|
className,
|
|
)}
|
|
>
|
|
<Icon />
|
|
<p className="text-sm text-gray-200 flex-1 pt-0.5">{item.message}</p>
|
|
<button
|
|
onClick={() => {
|
|
setExiting(true);
|
|
setTimeout(() => onDismiss(item.id), 200);
|
|
}}
|
|
className="text-gray-500 hover:text-gray-300 transition-colors shrink-0 pt-0.5"
|
|
aria-label="Dismiss"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ---------- Provider ---------- */
|
|
|
|
export interface ToastProviderProps {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
let globalId = 0;
|
|
|
|
export const ToastProvider: React.FC<ToastProviderProps> = ({ children, className }) => {
|
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
|
|
const dismiss = useCallback((id: string) => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
}, []);
|
|
|
|
const dismissAll = useCallback(() => setToasts([]), []);
|
|
|
|
const toast = useCallback(
|
|
(variant: ToastVariant, message: string, duration?: number): string => {
|
|
const id = `toast-${++globalId}`;
|
|
setToasts((prev) => [...prev, { id, variant, message, duration }]);
|
|
return id;
|
|
},
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ toast, dismiss, dismissAll }}>
|
|
{children}
|
|
|
|
{/* Toast container — bottom-right */}
|
|
<div
|
|
className={twMerge(
|
|
'fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-96 max-w-[calc(100vw-2rem)]',
|
|
className,
|
|
)}
|
|
aria-live="polite"
|
|
>
|
|
{toasts.map((t) => (
|
|
<ToastCard key={t.id} item={t} onDismiss={dismiss} />
|
|
))}
|
|
</div>
|
|
</ToastContext.Provider>
|
|
);
|
|
};
|
|
|
|
ToastProvider.displayName = 'ToastProvider';
|