auto-backup: 2025-12-11 20:35:05 (68 files: +19 ~23 -25)

Generated by DSS Git Backup Hook
This commit is contained in:
2025-12-11 17:35:05 -03:00
parent 09b234a07f
commit 1ff198c177
68 changed files with 3229 additions and 7102 deletions

View File

@@ -4,6 +4,7 @@ import { theme, initializeApp } from './state';
import { Shell } from './components/layout/Shell';
import { CommandPalette } from './components/shared/CommandPalette';
import { ToastContainer } from './components/shared/Toast';
import { ErrorBoundary } from './components/shared/ErrorBoundary';
import { useKeyboardShortcuts } from './hooks/useKeyboard';
export function App() {
@@ -43,10 +44,10 @@ export function App() {
}, []);
return (
<>
<ErrorBoundary>
<Shell />
<CommandPalette />
<ToastContainer />
</>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,141 @@
/* Skeleton Loader Styles */
.skeleton {
background-color: var(--color-muted);
display: block;
}
/* Variants */
.skeleton-text {
height: 1em;
border-radius: var(--radius-sm);
margin-bottom: var(--spacing-2);
}
.skeleton-text:last-child {
margin-bottom: 0;
}
.skeleton-circular {
border-radius: 50%;
}
.skeleton-rectangular {
border-radius: 0;
}
.skeleton-rounded {
border-radius: var(--radius-md);
}
/* Animations */
.skeleton-pulse {
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
.skeleton-wave {
position: relative;
overflow: hidden;
}
.skeleton-wave::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: skeleton-wave 1.5s linear infinite;
}
@keyframes skeleton-wave {
100% {
transform: translateX(100%);
}
}
[data-theme="dark"] .skeleton-wave::after {
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
}
/* Skeleton patterns */
.skeleton-text-block {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.skeleton-card {
background-color: var(--color-surface-0);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
}
.skeleton-card-content {
padding: var(--spacing-4);
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.skeleton-list-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3) 0;
}
.skeleton-list-content {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.skeleton-table {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.skeleton-table-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: var(--spacing-4);
padding: var(--spacing-3) 0;
border-bottom: 1px solid var(--color-border);
}
.skeleton-table-header {
padding-bottom: var(--spacing-3);
border-bottom: 2px solid var(--color-border);
}
.skeleton-table-row:last-child {
border-bottom: none;
}

View File

@@ -0,0 +1,106 @@
import { JSX } from 'preact';
import './Skeleton.css';
export interface SkeletonProps {
variant?: 'text' | 'circular' | 'rectangular' | 'rounded';
width?: string | number;
height?: string | number;
animation?: 'pulse' | 'wave' | 'none';
className?: string;
style?: JSX.CSSProperties;
}
export function Skeleton({
variant = 'text',
width,
height,
animation = 'pulse',
className = '',
style
}: SkeletonProps) {
const classes = [
'skeleton',
`skeleton-${variant}`,
animation !== 'none' && `skeleton-${animation}`,
className
].filter(Boolean).join(' ');
const combinedStyle: JSX.CSSProperties = {
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
...style
};
return <div className={classes} style={combinedStyle} />;
}
// Predefined skeleton patterns
export function SkeletonText({ lines = 3, className = '' }: { lines?: number; className?: string }) {
return (
<div className={`skeleton-text-block ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
width={i === lines - 1 ? '60%' : '100%'}
/>
))}
</div>
);
}
export function SkeletonCard({ className = '' }: { className?: string }) {
return (
<div className={`skeleton-card ${className}`}>
<Skeleton variant="rectangular" height={140} />
<div className="skeleton-card-content">
<Skeleton variant="text" width="80%" />
<Skeleton variant="text" width="60%" />
</div>
</div>
);
}
export function SkeletonAvatar({ size = 40, className = '' }: { size?: number; className?: string }) {
return (
<Skeleton
variant="circular"
width={size}
height={size}
className={className}
/>
);
}
export function SkeletonListItem({ className = '' }: { className?: string }) {
return (
<div className={`skeleton-list-item ${className}`}>
<SkeletonAvatar size={40} />
<div className="skeleton-list-content">
<Skeleton variant="text" width="40%" />
<Skeleton variant="text" width="70%" />
</div>
</div>
);
}
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: { rows?: number; columns?: number; className?: string }) {
return (
<div className={`skeleton-table ${className}`}>
{/* Header */}
<div className="skeleton-table-row skeleton-table-header">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} variant="text" width="80%" />
))}
</div>
{/* Body */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="skeleton-table-row">
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton key={colIndex} variant="text" width={colIndex === 0 ? '90%' : '60%'} />
))}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,164 @@
/* DataTable Styles */
.data-table-container {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.data-table-search {
max-width: 300px;
}
.data-table-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: var(--spacing-8);
color: var(--color-muted-foreground);
}
.data-table-wrapper {
overflow-x: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
.data-table th,
.data-table td {
padding: var(--spacing-3) var(--spacing-4);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.data-table th {
background-color: var(--color-surface-1);
font-weight: var(--font-weight-medium);
color: var(--color-muted-foreground);
white-space: nowrap;
}
.data-table th.sortable {
cursor: pointer;
user-select: none;
}
.data-table th.sortable:hover {
background-color: var(--color-muted);
}
.th-content {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.sort-indicator {
font-size: var(--font-size-xs);
color: var(--color-primary);
}
.data-table tbody tr {
transition: background-color var(--duration-fast) var(--timing-out);
}
.data-table tbody tr:hover {
background-color: var(--color-surface-1);
}
.data-table tbody tr.clickable {
cursor: pointer;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table td {
color: var(--color-foreground);
}
.empty-message {
text-align: center;
color: var(--color-muted-foreground);
padding: var(--spacing-8) !important;
}
.actions-column {
width: 100px;
text-align: right !important;
}
.actions-cell {
text-align: right;
white-space: nowrap;
}
.actions-cell > * {
margin-left: var(--spacing-1);
}
/* Pagination */
.data-table-pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--spacing-3);
}
.pagination-info {
font-size: var(--font-size-sm);
color: var(--color-muted-foreground);
}
.pagination-controls {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.page-indicator {
padding: 0 var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted-foreground);
}
/* Code in cells */
.data-table td code {
padding: var(--spacing-1) var(--spacing-2);
background-color: var(--color-surface-1);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-mono);
}
/* Status badges in cells */
.data-table td .badge {
margin: 0;
}
/* Responsive */
@media (max-width: 640px) {
.data-table th,
.data-table td {
padding: var(--spacing-2) var(--spacing-3);
}
.data-table-pagination {
flex-direction: column;
align-items: stretch;
}
.pagination-controls {
justify-content: center;
}
}

View File

@@ -0,0 +1,249 @@
import { ComponentChildren } from 'preact';
import { useState, useMemo } from 'preact/hooks';
import { Spinner } from '../base/Spinner';
import { Input } from '../base/Input';
import { Button } from '../base/Button';
import './DataTable.css';
export interface Column<T> {
key: string;
header: string;
width?: string;
sortable?: boolean;
render?: (value: unknown, row: T, index: number) => ComponentChildren;
}
export interface DataTableProps<T extends Record<string, unknown>> {
columns: Column<T>[];
data: T[];
loading?: boolean;
emptyMessage?: string;
searchable?: boolean;
searchKeys?: string[];
sortable?: boolean;
pagination?: boolean;
pageSize?: number;
onRowClick?: (row: T, index: number) => void;
rowClassName?: (row: T, index: number) => string;
actions?: (row: T, index: number) => ComponentChildren;
}
type SortDirection = 'asc' | 'desc' | null;
export function DataTable<T extends Record<string, unknown>>({
columns,
data,
loading = false,
emptyMessage = 'No data available',
searchable = false,
searchKeys = [],
sortable = true,
pagination = true,
pageSize = 10,
onRowClick,
rowClassName,
actions
}: DataTableProps<T>) {
const [searchTerm, setSearchTerm] = useState('');
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
const [currentPage, setCurrentPage] = useState(1);
// Filter data based on search
const filteredData = useMemo(() => {
if (!searchTerm || searchKeys.length === 0) return data;
const term = searchTerm.toLowerCase();
return data.filter(row =>
searchKeys.some(key => {
const value = row[key];
return value && String(value).toLowerCase().includes(term);
})
);
}, [data, searchTerm, searchKeys]);
// Sort data
const sortedData = useMemo(() => {
if (!sortColumn || !sortDirection) return filteredData;
return [...filteredData].sort((a, b) => {
const aVal = a[sortColumn];
const bVal = b[sortColumn];
if (aVal === bVal) return 0;
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
const comparison = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
return sortDirection === 'asc' ? comparison : -comparison;
});
}, [filteredData, sortColumn, sortDirection]);
// Paginate data
const paginatedData = useMemo(() => {
if (!pagination) return sortedData;
const start = (currentPage - 1) * pageSize;
return sortedData.slice(start, start + pageSize);
}, [sortedData, currentPage, pageSize, pagination]);
const totalPages = Math.ceil(sortedData.length / pageSize);
// Handle sort toggle
function handleSort(columnKey: string) {
if (!sortable) return;
const column = columns.find(c => c.key === columnKey);
if (column?.sortable === false) return;
if (sortColumn === columnKey) {
if (sortDirection === 'asc') {
setSortDirection('desc');
} else if (sortDirection === 'desc') {
setSortColumn(null);
setSortDirection(null);
}
} else {
setSortColumn(columnKey);
setSortDirection('asc');
}
}
// Reset to page 1 when search changes
const handleSearch = (value: string) => {
setSearchTerm(value);
setCurrentPage(1);
};
if (loading) {
return (
<div className="data-table-loading">
<Spinner size="lg" />
<span>Loading data...</span>
</div>
);
}
return (
<div className="data-table-container">
{searchable && (
<div className="data-table-search">
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => handleSearch((e.target as HTMLInputElement).value)}
size="sm"
/>
</div>
)}
<div className="data-table-wrapper">
<table className="data-table">
<thead>
<tr>
{columns.map(column => (
<th
key={column.key}
style={column.width ? { width: column.width } : undefined}
className={sortable && column.sortable !== false ? 'sortable' : ''}
onClick={() => sortable && column.sortable !== false && handleSort(column.key)}
>
<span className="th-content">
{column.header}
{sortColumn === column.key && (
<span className="sort-indicator">
{sortDirection === 'asc' ? ' \u2191' : ' \u2193'}
</span>
)}
</span>
</th>
))}
{actions && <th className="actions-column">Actions</th>}
</tr>
</thead>
<tbody>
{paginatedData.length === 0 ? (
<tr>
<td colSpan={columns.length + (actions ? 1 : 0)} className="empty-message">
{emptyMessage}
</td>
</tr>
) : (
paginatedData.map((row, index) => {
const actualIndex = (currentPage - 1) * pageSize + index;
return (
<tr
key={actualIndex}
className={[
onRowClick ? 'clickable' : '',
rowClassName ? rowClassName(row, actualIndex) : ''
].filter(Boolean).join(' ')}
onClick={() => onRowClick?.(row, actualIndex)}
>
{columns.map(column => (
<td key={column.key}>
{column.render
? column.render(row[column.key], row, actualIndex)
: String(row[column.key] ?? '-')}
</td>
))}
{actions && (
<td className="actions-cell">
{actions(row, actualIndex)}
</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
{pagination && totalPages > 1 && (
<div className="data-table-pagination">
<span className="pagination-info">
Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sortedData.length)} of {sortedData.length}
</span>
<div className="pagination-controls">
<Button
variant="ghost"
size="sm"
disabled={currentPage === 1}
onClick={() => setCurrentPage(1)}
>
First
</Button>
<Button
variant="ghost"
size="sm"
disabled={currentPage === 1}
onClick={() => setCurrentPage(p => p - 1)}
>
Prev
</Button>
<span className="page-indicator">
{currentPage} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage(p => p + 1)}
>
Next
</Button>
<Button
variant="ghost"
size="sm"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage(totalPages)}
>
Last
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
/* Error Boundary Styles */
.error-boundary {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
padding: var(--spacing-6);
}
.error-boundary .card {
max-width: 500px;
width: 100%;
}
.error-details {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.error-message {
padding: var(--spacing-3);
background-color: var(--color-error-bg);
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
color: var(--color-error);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
word-break: break-word;
}
.error-stack {
margin-top: var(--spacing-2);
}
.error-stack summary {
cursor: pointer;
font-size: var(--font-size-sm);
color: var(--color-muted-foreground);
padding: var(--spacing-2) 0;
}
.error-stack summary:hover {
color: var(--color-foreground);
}
.error-stack pre {
margin-top: var(--spacing-2);
padding: var(--spacing-3);
background-color: var(--color-surface-1);
border-radius: var(--radius-md);
font-size: var(--font-size-xs);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
.error-boundary .card-footer {
gap: var(--spacing-3);
}

View File

@@ -0,0 +1,104 @@
import { Component, ComponentChildren } from 'preact';
import { Button } from '../base/Button';
import { Card, CardHeader, CardContent, CardFooter } from '../base/Card';
import './ErrorBoundary.css';
interface ErrorBoundaryProps {
children: ComponentChildren;
fallback?: ComponentChildren;
onError?: (error: Error, errorInfo: { componentStack: string }) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: { componentStack: string } | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = {
hasError: false,
error: null,
errorInfo: null
};
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: { componentStack: string }) {
this.setState({ errorInfo });
// Log error to console
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Call optional error handler
this.props.onError?.(error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
};
handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
// Custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<div className="error-boundary">
<Card variant="bordered" padding="lg">
<CardHeader
title="Something went wrong"
subtitle="An unexpected error occurred"
/>
<CardContent>
<div className="error-details">
<p className="error-message">
{this.state.error?.message || 'Unknown error'}
</p>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
<details className="error-stack">
<summary>Stack trace</summary>
<pre>{this.state.errorInfo.componentStack}</pre>
</details>
)}
</div>
</CardContent>
<CardFooter>
<Button variant="primary" onClick={this.handleReset}>
Try Again
</Button>
<Button variant="outline" onClick={this.handleReload}>
Reload Page
</Button>
</CardFooter>
</Card>
</div>
);
}
return this.props.children;
}
}
// Hook-based wrapper for functional components
export function withErrorBoundary<P extends object>(
WrappedComponent: (props: P) => ComponentChildren,
fallback?: ComponentChildren
) {
return function WithErrorBoundary(props: P) {
return (
<ErrorBoundary fallback={fallback}>
<WrappedComponent {...props} />
</ErrorBoundary>
);
};
}

View File

@@ -0,0 +1,165 @@
/* Modal Styles */
.modal-overlay {
position: fixed;
inset: 0;
z-index: var(--z-50);
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-4);
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
animation: fadeIn var(--duration-fast) var(--timing-out);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
display: flex;
flex-direction: column;
background-color: var(--color-surface-0);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
max-height: calc(100vh - var(--spacing-8));
animation: slideUp var(--duration-normal) var(--timing-out);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Modal sizes */
.modal-sm {
width: 100%;
max-width: 400px;
}
.modal-md {
width: 100%;
max-width: 560px;
}
.modal-lg {
width: 100%;
max-width: 720px;
}
.modal-xl {
width: 100%;
max-width: 960px;
}
.modal-full {
width: calc(100vw - var(--spacing-8));
max-width: none;
height: calc(100vh - var(--spacing-8));
}
/* Modal header */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4) var(--spacing-6);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.modal-title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-foreground);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
margin: calc(var(--spacing-1) * -1);
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--color-muted-foreground);
cursor: pointer;
transition: all var(--duration-fast) var(--timing-out);
}
.modal-close:hover {
background-color: var(--color-muted);
color: var(--color-foreground);
}
.modal-close:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Modal content */
.modal-content {
flex: 1;
padding: var(--spacing-6);
overflow-y: auto;
}
/* Modal footer */
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
padding: var(--spacing-4) var(--spacing-6);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
/* Confirm dialog specific */
.confirm-message {
margin: 0;
color: var(--color-muted-foreground);
line-height: var(--line-height-relaxed);
}
/* Dark mode adjustments */
[data-theme="dark"] .modal-overlay {
background-color: rgba(0, 0, 0, 0.7);
}
/* Responsive */
@media (max-width: 640px) {
.modal-overlay {
padding: var(--spacing-2);
align-items: flex-end;
}
.modal {
max-height: calc(100vh - var(--spacing-4));
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.modal-sm,
.modal-md,
.modal-lg,
.modal-xl {
max-width: none;
}
}

View File

@@ -0,0 +1,152 @@
import { JSX, ComponentChildren } from 'preact';
import { useEffect, useCallback } from 'preact/hooks';
import { signal } from '@preact/signals';
import { Button } from '../base/Button';
import './Modal.css';
// Global modal state
export const modalOpen = signal(false);
export const modalContent = signal<ComponentChildren | null>(null);
export const modalTitle = signal<string>('');
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ComponentChildren;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
footer?: ComponentChildren;
}
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
closeOnOverlayClick = true,
closeOnEscape = true,
showCloseButton = true,
footer
}: ModalProps) {
// Handle escape key
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' && closeOnEscape) {
onClose();
}
}, [closeOnEscape, onClose]);
// Handle overlay click
const handleOverlayClick = useCallback((e: MouseEvent) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose();
}
}, [closeOnOverlayClick, onClose]);
// Add/remove event listeners and body scroll lock
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={handleOverlayClick as unknown as JSX.MouseEventHandler<HTMLDivElement>}>
<div className={`modal modal-${size}`} role="dialog" aria-modal="true" aria-labelledby={title ? 'modal-title' : undefined}>
{(title || showCloseButton) && (
<div className="modal-header">
{title && <h2 id="modal-title" className="modal-title">{title}</h2>}
{showCloseButton && (
<button className="modal-close" onClick={onClose} aria-label="Close modal">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
)}
<div className="modal-content">
{children}
</div>
{footer && (
<div className="modal-footer">
{footer}
</div>
)}
</div>
</div>
);
}
// Confirm dialog helper
export interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'default';
loading?: boolean;
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'default',
loading = false
}: ConfirmDialogProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={loading}>
{cancelText}
</Button>
<Button
variant={variant === 'danger' ? 'danger' : 'primary'}
onClick={onConfirm}
loading={loading}
>
{confirmText}
</Button>
</>
}
>
<p className="confirm-message">{message}</p>
</Modal>
);
}
// Hook for managing modal state
export function useModal() {
const isOpen = signal(false);
const open = () => { isOpen.value = true; };
const close = () => { isOpen.value = false; };
const toggle = () => { isOpen.value = !isOpen.value; };
return { isOpen: isOpen.value, open, close, toggle };
}

View File

@@ -50,11 +50,12 @@ export const TEAM_CONFIGS: Record<TeamId, TeamConfig> = {
description: 'Design consistency & token validation',
tools: [
{ id: 'dashboard', name: 'Dashboard', description: 'Team metrics and quick actions' },
{ id: 'live-canvas', name: 'Live Canvas', description: 'AI-powered component generator - your own Figma Make' },
{ id: 'figma-files', name: 'Figma Files', description: 'Manage connected Figma files' },
{ id: 'figma-plugin', name: 'Figma Plugin', description: 'Export tokens/assets/components from Figma' },
{ id: 'token-list', name: 'Token List', description: 'View all design tokens' },
{ id: 'asset-list', name: 'Asset List', description: 'Gallery of design assets' },
{ id: 'component-list', name: 'Component List', description: 'Design system components' },
{ id: 'navigation-demos', name: 'Navigation Demos', description: 'Generate navigation flow demos' }
{ id: 'component-list', name: 'Component List', description: 'Design system components' }
],
panels: ['metrics', 'diff', 'accessibility', 'screenshots', 'chat'],
metrics: ['figmaFiles', 'syncedFiles', 'pendingSync', 'designTokens'],
@@ -69,8 +70,8 @@ export const TEAM_CONFIGS: Record<TeamId, TeamConfig> = {
{ id: 'figma-live-compare', name: 'Figma vs Live', description: 'QA validation: Figma design vs live implementation' },
{ id: 'esre-editor', name: 'ESRE Editor', description: 'Edit Explicit Style Requirements and Expectations' },
{ id: 'console-viewer', name: 'Console Viewer', description: 'Monitor browser console logs', mcpTool: 'browser_get_logs' },
{ id: 'network-monitor', name: 'Network Monitor', description: 'Track network requests', mcpTool: 'devtools_network_requests' },
{ id: 'error-tracker', name: 'Error Tracker', description: 'Track uncaught exceptions', mcpTool: 'browser_get_errors' }
{ id: 'network-monitor', name: 'Network Monitor', description: 'Track network requests in real-time' },
{ id: 'test-results', name: 'Test Results', description: 'View ESRE test results and history' }
],
panels: ['metrics', 'console', 'network', 'tests', 'chat'],
metrics: ['healthScore', 'esreDefinitions', 'testsRun', 'testsPassed'],
@@ -84,6 +85,7 @@ export const TEAM_CONFIGS: Record<TeamId, TeamConfig> = {
{ id: 'settings', name: 'System Settings', description: 'Configure DSS hostname, port, and setup type' },
{ id: 'projects', name: 'Projects', description: 'Create and manage design system projects' },
{ id: 'integrations', name: 'Integrations', description: 'Configure Figma, Jira, and other integrations' },
{ id: 'mcp-tools', name: 'MCP Tools', description: 'View and execute MCP tools for AI assistants' },
{ id: 'audit-log', name: 'Audit Log', description: 'View all system activity' },
{ id: 'cache-management', name: 'Cache Management', description: 'Clear and manage system cache' },
{ id: 'health-monitor', name: 'Health Monitor', description: 'System health dashboard' },

View File

@@ -6,7 +6,7 @@ import { Badge } from '../components/base/Badge';
import { Input, Select } from '../components/base/Input';
import { Spinner } from '../components/base/Spinner';
import { endpoints } from '../api/client';
import type { Project, RuntimeConfig, AuditEntry, SystemHealth, Service } from '../api/types';
import type { Project, RuntimeConfig, AuditEntry, SystemHealth, Service, MCPTool } from '../api/types';
import './Workdesk.css';
interface AdminWorkdeskProps {
@@ -21,6 +21,7 @@ export default function AdminWorkdesk({ activeTool }: AdminWorkdeskProps) {
const toolViews: Record<string, JSX.Element> = {
'projects': <ProjectsTool />,
'integrations': <IntegrationsTool />,
'mcp-tools': <MCPToolsTool />,
'audit-log': <AuditLogTool />,
'cache-management': <CacheManagementTool />,
'health-monitor': <HealthMonitorTool />,
@@ -448,6 +449,173 @@ function IntegrationsTool() {
);
}
function MCPToolsTool() {
const [tools, setTools] = useState<MCPTool[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTool, setSelectedTool] = useState<MCPTool | null>(null);
const [executing, setExecuting] = useState(false);
const [params, setParams] = useState<Record<string, string>>({});
const [result, setResult] = useState<unknown | null>(null);
const [error, setError] = useState<string | null>(null);
const [mcpStatus, setMcpStatus] = useState<{ connected: boolean; tools: number } | null>(null);
useEffect(() => {
loadTools();
}, []);
async function loadTools() {
setLoading(true);
try {
const [toolsResult, statusResult] = await Promise.allSettled([
endpoints.mcp.tools(),
endpoints.mcp.status()
]);
if (toolsResult.status === 'fulfilled') {
setTools(toolsResult.value);
}
if (statusResult.status === 'fulfilled') {
setMcpStatus(statusResult.value);
}
} catch (err) {
console.error('Failed to load MCP tools:', err);
} finally {
setLoading(false);
}
}
async function handleExecute() {
if (!selectedTool) return;
setExecuting(true);
setError(null);
setResult(null);
try {
const response = await endpoints.mcp.execute(selectedTool.name, params);
setResult(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'Execution failed');
} finally {
setExecuting(false);
}
}
function handleSelectTool(tool: MCPTool) {
setSelectedTool(tool);
setParams({});
setResult(null);
setError(null);
}
if (loading) {
return (
<div className="workdesk">
<div className="workdesk-loading">
<Spinner size="lg" />
<span>Loading MCP tools...</span>
</div>
</div>
);
}
return (
<div className="workdesk">
<div className="workdesk-header">
<h1 className="workdesk-title">MCP Tools</h1>
<p className="workdesk-subtitle">Model Context Protocol tools for AI assistants</p>
</div>
{/* MCP Status */}
{mcpStatus && (
<div className={`connection-status ${mcpStatus.connected ? 'connected' : 'disconnected'}`}>
<Badge variant={mcpStatus.connected ? 'success' : 'error'} size="sm">
MCP: {mcpStatus.connected ? 'Connected' : 'Not Connected'}
</Badge>
<span className="tools-count">{mcpStatus.tools} tools available</span>
</div>
)}
{/* Tools List */}
<Card variant="bordered" padding="md">
<CardHeader
title="Available Tools"
subtitle={`${tools.length} tools`}
action={<Button variant="ghost" size="sm" onClick={loadTools}>Refresh</Button>}
/>
<CardContent>
{tools.length === 0 ? (
<p className="text-muted">No MCP tools available</p>
) : (
<div className="mcp-tools-list">
{tools.map(tool => (
<div
key={tool.name}
className={`mcp-tool-item ${selectedTool?.name === tool.name ? 'selected' : ''}`}
onClick={() => handleSelectTool(tool)}
>
<div className="mcp-tool-info">
<span className="mcp-tool-name">{tool.name}</span>
<span className="mcp-tool-desc">{tool.description}</span>
</div>
{tool.category && <Badge size="sm">{tool.category}</Badge>}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Tool Execution */}
{selectedTool && (
<Card variant="bordered" padding="md">
<CardHeader
title={selectedTool.name}
subtitle={selectedTool.description}
/>
<CardContent>
<div className="mcp-tool-params">
{Object.entries(selectedTool.input_schema.properties || {}).map(([key, prop]) => (
<Input
key={key}
label={`${key}${selectedTool.input_schema.required?.includes(key) ? ' *' : ''}`}
value={params[key] || ''}
onChange={(e) => setParams(p => ({ ...p, [key]: (e.target as HTMLInputElement).value }))}
placeholder={prop.description || `Enter ${key}`}
hint={prop.type}
fullWidth
/>
))}
</div>
{error && (
<div className="form-error">
<Badge variant="error">{error}</Badge>
</div>
)}
</CardContent>
<CardFooter>
<Button variant="primary" onClick={handleExecute} loading={executing}>
Execute Tool
</Button>
</CardFooter>
</Card>
)}
{/* Results */}
{result && (
<Card variant="bordered" padding="md">
<CardHeader title="Execution Result" />
<CardContent>
<pre className="mcp-result">
{JSON.stringify(result, null, 2)}
</pre>
</CardContent>
</Card>
)}
</div>
);
}
function AuditLogTool() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -22,6 +22,7 @@ export default function QAWorkdesk({ activeTool }: QAWorkdeskProps) {
const toolViews: Record<string, JSX.Element> = {
'esre-editor': <ESREEditorTool />,
'console-viewer': <ConsoleViewerTool />,
'network-monitor': <NetworkMonitorTool />,
'figma-live-compare': <FigmaLiveCompareTool />,
'test-results': <TestResultsTool />,
};
@@ -532,6 +533,147 @@ function ConsoleViewerTool() {
);
}
function NetworkMonitorTool() {
const [requests, setRequests] = useState<Array<{
id: number;
method: string;
url: string;
status: number | null;
duration: number | null;
size: string | null;
type: string;
timestamp: string;
}>>([]);
const [filter, setFilter] = useState('all');
const [isCapturing, setIsCapturing] = useState(false);
useEffect(() => {
if (!isCapturing) return;
// Intercept fetch requests
const originalFetch = window.fetch;
window.fetch = async function(...args) {
const startTime = performance.now();
const url = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url;
const method = typeof args[0] === 'string' ? (args[1]?.method || 'GET') : (args[0] as Request).method || 'GET';
const requestId = Date.now();
// Add pending request
setRequests(prev => [...prev.slice(-99), {
id: requestId,
method: method.toUpperCase(),
url,
status: null,
duration: null,
size: null,
type: 'fetch',
timestamp: new Date().toLocaleTimeString()
}]);
try {
const response = await originalFetch.apply(this, args);
const duration = Math.round(performance.now() - startTime);
const contentLength = response.headers.get('content-length');
// Update with response data
setRequests(prev => prev.map(r =>
r.id === requestId
? { ...r, status: response.status, duration, size: contentLength ? `${Math.round(parseInt(contentLength) / 1024)}KB` : null }
: r
));
return response;
} catch (error) {
setRequests(prev => prev.map(r =>
r.id === requestId
? { ...r, status: 0, duration: Math.round(performance.now() - startTime) }
: r
));
throw error;
}
};
return () => {
window.fetch = originalFetch;
};
}, [isCapturing]);
const filteredRequests = filter === 'all'
? requests
: requests.filter(r => {
if (filter === 'success') return r.status !== null && r.status >= 200 && r.status < 400;
if (filter === 'error') return r.status === null || r.status === 0 || r.status >= 400;
return true;
});
return (
<div className="workdesk">
<div className="workdesk-header">
<h1 className="workdesk-title">Network Monitor</h1>
<p className="workdesk-subtitle">Track network requests in real-time</p>
</div>
<Card variant="bordered" padding="md">
<CardHeader
title="Network Requests"
action={
<div className="network-controls">
<Select
size="sm"
value={filter}
onChange={(e) => setFilter((e.target as HTMLSelectElement).value)}
options={[
{ value: 'all', label: 'All' },
{ value: 'success', label: 'Success' },
{ value: 'error', label: 'Errors' }
]}
/>
<Button
variant={isCapturing ? 'danger' : 'primary'}
size="sm"
onClick={() => setIsCapturing(!isCapturing)}
>
{isCapturing ? 'Stop Capture' : 'Start Capture'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRequests([])}>Clear</Button>
</div>
}
/>
<CardContent>
{!isCapturing && requests.length === 0 ? (
<p className="text-muted">Click "Start Capture" to begin monitoring network requests</p>
) : (
<div className="network-list">
{filteredRequests.length === 0 ? (
<p className="text-muted">No requests captured yet</p>
) : (
filteredRequests.map(request => (
<div key={request.id} className={`network-item ${request.status === null ? 'pending' : request.status >= 400 || request.status === 0 ? 'error' : 'success'}`}>
<Badge
variant={request.status === null ? 'warning' : request.status >= 400 || request.status === 0 ? 'error' : 'success'}
size="sm"
>
{request.method}
</Badge>
<span className="network-url">{request.url}</span>
<span className="network-status">
{request.status === null ? 'Pending' : request.status === 0 ? 'Error' : request.status}
</span>
<span className="network-duration">{request.duration ? `${request.duration}ms` : '-'}</span>
<span className="network-size">{request.size || '-'}</span>
<span className="network-time">{request.timestamp}</span>
</div>
))
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
function FigmaLiveCompareTool() {
const [figmaUrl, setFigmaUrl] = useState('');
const [liveUrl, setLiveUrl] = useState('');

View File

@@ -1,5 +1,5 @@
import { JSX } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { useState, useEffect, useRef } from 'preact/hooks';
import { Card, CardHeader, CardContent } from '../components/base/Card';
import { Button } from '../components/base/Button';
import { Badge } from '../components/base/Badge';
@@ -21,8 +21,11 @@ export default function UXWorkdesk({ activeTool }: UXWorkdeskProps) {
const toolViews: Record<string, JSX.Element> = {
'token-list': <TokenListTool />,
'asset-list': <AssetListTool />,
'component-list': <ComponentListTool />,
'figma-plugin': <FigmaPluginTool />,
'figma-files': <FigmaFilesTool />,
'live-canvas': <LiveCanvasTool />,
};
return toolViews[activeTool] || <ToolPlaceholder name={activeTool} />;
@@ -492,6 +495,228 @@ function FigmaFilesTool() {
);
}
function AssetListTool() {
const [assets, setAssets] = useState<Array<{
id: string;
name: string;
type: 'icon' | 'image' | 'illustration';
format: string;
size: string;
url?: string;
}>>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
useEffect(() => {
loadAssets();
}, []);
async function loadAssets() {
setLoading(true);
try {
const projectId = currentProject.value?.id;
if (projectId) {
// Try to load assets from Figma extraction
const files = await endpoints.projects.figmaFiles(projectId);
if (files.length > 0) {
const result = await endpoints.figma.extractStyles(files[0].file_key);
// Transform styles into assets
const extractedAssets = result.items
.filter(item => item.type === 'effect' || item.type === 'paint')
.map((item, idx) => ({
id: String(idx),
name: item.name,
type: 'icon' as const,
format: 'svg',
size: '-'
}));
setAssets(extractedAssets);
}
}
} catch (err) {
console.error('Failed to load assets:', err);
// Fallback demo data
setAssets([
{ id: '1', name: 'icon-check', type: 'icon', format: 'svg', size: '24x24' },
{ id: '2', name: 'icon-close', type: 'icon', format: 'svg', size: '24x24' },
{ id: '3', name: 'icon-menu', type: 'icon', format: 'svg', size: '24x24' },
{ id: '4', name: 'logo-primary', type: 'image', format: 'png', size: '200x50' },
{ id: '5', name: 'hero-illustration', type: 'illustration', format: 'svg', size: '800x600' }
]);
} finally {
setLoading(false);
}
}
const filteredAssets = assets.filter(asset => {
const matchesSearch = !searchTerm || asset.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || asset.type === typeFilter;
return matchesSearch && matchesType;
});
if (loading) {
return (
<div className="workdesk">
<div className="workdesk-loading">
<Spinner size="lg" />
<span>Loading assets...</span>
</div>
</div>
);
}
return (
<div className="workdesk">
<div className="workdesk-header">
<h1 className="workdesk-title">Asset List</h1>
<p className="workdesk-subtitle">Design assets and icons from Figma</p>
</div>
<Card variant="bordered" padding="md">
<CardHeader
title="Assets"
subtitle={`${filteredAssets.length} assets`}
action={
<div className="asset-controls">
<Input
placeholder="Search assets..."
size="sm"
value={searchTerm}
onChange={(e) => setSearchTerm((e.target as HTMLInputElement).value)}
/>
<select
className="select-sm"
value={typeFilter}
onChange={(e) => setTypeFilter((e.target as HTMLSelectElement).value)}
>
<option value="all">All Types</option>
<option value="icon">Icons</option>
<option value="image">Images</option>
<option value="illustration">Illustrations</option>
</select>
</div>
}
/>
<CardContent>
{filteredAssets.length === 0 ? (
<p className="text-muted">No assets found</p>
) : (
<div className="assets-grid">
{filteredAssets.map(asset => (
<div key={asset.id} className="asset-item">
<div className="asset-preview">
<span className="asset-icon">{asset.type === 'icon' ? '\u25A1' : asset.type === 'image' ? '\u25A3' : '\u25A2'}</span>
</div>
<div className="asset-info">
<span className="asset-name">{asset.name}</span>
<span className="asset-meta">{asset.format} | {asset.size}</span>
</div>
<Badge size="sm">{asset.type}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
function ComponentListTool() {
const [components, setComponents] = useState<Array<{
id: string;
name: string;
description?: string;
variants?: number;
status: 'ready' | 'in-progress' | 'planned';
}>>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadComponents();
}, []);
async function loadComponents() {
setLoading(true);
try {
const projectId = currentProject.value?.id;
if (projectId) {
const result = await endpoints.projects.components(projectId);
setComponents(result.map(c => ({
id: c.id,
name: c.display_name || c.name,
description: c.description,
variants: c.variants?.length || 0,
status: (c as unknown as { status?: string }).status === 'active' ? 'ready' : 'in-progress'
})));
}
} catch (err) {
console.error('Failed to load components:', err);
setComponents([]);
} finally {
setLoading(false);
}
}
if (loading) {
return (
<div className="workdesk">
<div className="workdesk-loading">
<Spinner size="lg" />
<span>Loading components...</span>
</div>
</div>
);
}
return (
<div className="workdesk">
<div className="workdesk-header">
<h1 className="workdesk-title">Component List</h1>
<p className="workdesk-subtitle">Design system components</p>
</div>
<Card variant="bordered" padding="md">
<CardHeader
title="Components"
subtitle={`${components.length} components`}
action={<Button variant="ghost" size="sm" onClick={loadComponents}>Refresh</Button>}
/>
<CardContent>
{components.length === 0 ? (
<p className="text-muted">No components found. Extract components from Figma first.</p>
) : (
<div className="components-list">
{components.map(component => (
<div key={component.id} className="component-item">
<div className="component-info">
<span className="component-name">{component.name}</span>
{component.description && (
<span className="component-desc">{component.description}</span>
)}
</div>
<div className="component-meta">
{component.variants && component.variants > 0 && (
<span className="component-variants">{component.variants} variants</span>
)}
<Badge
variant={component.status === 'ready' ? 'success' : component.status === 'in-progress' ? 'warning' : 'default'}
size="sm"
>
{component.status}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
function FigmaPluginTool() {
return (
<div className="workdesk">
@@ -523,6 +748,317 @@ function FigmaPluginTool() {
);
}
interface GeneratedComponent {
id: string;
prompt: string;
code: string;
timestamp: number;
}
function LiveCanvasTool() {
const [prompt, setPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generatedCode, setGeneratedCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<GeneratedComponent[]>([]);
const [viewport, setViewport] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
const iframeRef = useRef<HTMLIFrameElement>(null);
const viewportSizes = {
desktop: { width: '100%', maxWidth: '1200px' },
tablet: { width: '768px', maxWidth: '768px' },
mobile: { width: '375px', maxWidth: '375px' }
};
async function handleGenerate() {
if (!prompt.trim()) return;
setIsGenerating(true);
setError(null);
try {
// Build the context for Claude
const context = {
team: 'ux',
request: 'generate_ui_component',
project: currentProject.value ? {
id: currentProject.value.id,
name: currentProject.value.name
} : null
};
// Create a detailed prompt for component generation
const componentPrompt = `Generate a React/Preact component for the following request. Return ONLY the JSX code that can be rendered directly in an iframe. Do not include imports or exports. Use inline styles or standard CSS class names. Make it visually complete and polished.
Request: ${prompt}
Requirements:
- Use modern, clean design principles
- Include proper spacing and typography
- Use a cohesive color scheme (prefer neutral/professional colors)
- Make it responsive
- Return ONLY the JSX code, nothing else`;
const response = await endpoints.claude.chat(componentPrompt, currentProject.value?.id, context);
if (response.message?.content) {
// Extract code from the response
let code = response.message.content;
// Try to extract code from markdown code blocks if present
const codeBlockMatch = code.match(/```(?:jsx?|tsx?|html)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
code = codeBlockMatch[1].trim();
}
setGeneratedCode(code);
// Add to history
const newComponent: GeneratedComponent = {
id: `gen-${Date.now()}`,
prompt: prompt,
code: code,
timestamp: Date.now()
};
setHistory(prev => [newComponent, ...prev.slice(0, 9)]);
// Render in iframe
renderInIframe(code);
} else {
setError('No response from Claude. Check your API key configuration.');
}
} catch (err) {
console.error('Generation failed:', err);
setError(err instanceof Error ? err.message : 'Failed to generate component');
} finally {
setIsGenerating(false);
}
}
function renderInIframe(code: string) {
const iframe = iframeRef.current;
if (!iframe) return;
// Create the HTML document to render in the iframe
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 20px;
background: #fff;
min-height: 100vh;
}
#root { width: 100%; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
function App() {
return (
<>
${code}
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>`;
// Write to iframe
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
doc.open();
doc.write(htmlContent);
doc.close();
}
}
function handleLoadFromHistory(item: GeneratedComponent) {
setPrompt(item.prompt);
setGeneratedCode(item.code);
renderInIframe(item.code);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleGenerate();
}
}
function handleRefresh() {
if (generatedCode) {
renderInIframe(generatedCode);
}
}
function handleCopyCode() {
if (generatedCode) {
navigator.clipboard.writeText(generatedCode);
}
}
return (
<div className="workdesk live-canvas-workdesk">
<div className="workdesk-header">
<h1 className="workdesk-title">Live Canvas</h1>
<p className="workdesk-subtitle">Generate UI components with AI - your own Figma Make</p>
</div>
{/* Prompt Input Area */}
<Card variant="bordered" padding="md">
<CardHeader title="Build with AI" subtitle="Describe what you want to build" />
<CardContent>
<div className="canvas-prompt-area">
<textarea
className="canvas-prompt-input"
placeholder="Describe the component you want to build... e.g., 'A login form with email and password fields, a remember me checkbox, and a submit button with modern styling'"
value={prompt}
onInput={(e) => setPrompt((e.target as HTMLTextAreaElement).value)}
onKeyDown={handleKeyDown}
rows={3}
disabled={isGenerating}
/>
<div className="canvas-prompt-actions">
<span className="canvas-hint">Press Cmd+Enter to generate</span>
<Button
variant="primary"
onClick={handleGenerate}
loading={isGenerating}
disabled={!prompt.trim()}
>
{isGenerating ? 'Generating...' : 'Generate Component'}
</Button>
</div>
</div>
{error && (
<div className="canvas-error">
<Badge variant="error">{error}</Badge>
</div>
)}
</CardContent>
</Card>
{/* Canvas Preview Area */}
<Card variant="bordered" padding="md" className="canvas-preview-card">
<CardHeader
title="Preview"
subtitle={generatedCode ? 'Live component preview' : 'Generate a component to see preview'}
action={
<div className="canvas-toolbar">
<div className="viewport-switcher">
<button
className={`viewport-btn ${viewport === 'desktop' ? 'active' : ''}`}
onClick={() => setViewport('desktop')}
title="Desktop"
>
<span>\u25A1</span>
</button>
<button
className={`viewport-btn ${viewport === 'tablet' ? 'active' : ''}`}
onClick={() => setViewport('tablet')}
title="Tablet"
>
<span>\u25A3</span>
</button>
<button
className={`viewport-btn ${viewport === 'mobile' ? 'active' : ''}`}
onClick={() => setViewport('mobile')}
title="Mobile"
>
<span>\u25AF</span>
</button>
</div>
<Button variant="ghost" size="sm" onClick={handleRefresh} disabled={!generatedCode}>
Refresh
</Button>
<Button variant="ghost" size="sm" onClick={handleCopyCode} disabled={!generatedCode}>
Copy Code
</Button>
</div>
}
/>
<CardContent>
<div className="canvas-frame-container">
<div
className="canvas-frame"
style={{
width: viewportSizes[viewport].width,
maxWidth: viewportSizes[viewport].maxWidth
}}
>
{generatedCode ? (
<iframe
ref={iframeRef}
className="canvas-iframe"
title="Component Preview"
sandbox="allow-scripts"
/>
) : (
<div className="canvas-placeholder">
<div className="canvas-placeholder-icon">\u2728</div>
<p>Your generated component will appear here</p>
<p className="text-muted">Try: "A pricing card with three tiers"</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Code Panel */}
{generatedCode && (
<Card variant="bordered" padding="md">
<CardHeader
title="Generated Code"
action={<Button variant="ghost" size="sm" onClick={handleCopyCode}>Copy</Button>}
/>
<CardContent>
<pre className="canvas-code-preview">
<code>{generatedCode}</code>
</pre>
</CardContent>
</Card>
)}
{/* History */}
{history.length > 0 && (
<Card variant="bordered" padding="md">
<CardHeader title="Recent Generations" subtitle={`${history.length} items`} />
<CardContent>
<div className="canvas-history">
{history.map(item => (
<div
key={item.id}
className="canvas-history-item"
onClick={() => handleLoadFromHistory(item)}
>
<span className="history-prompt">{item.prompt.slice(0, 60)}...</span>
<span className="history-time">{formatTimeAgo(new Date(item.timestamp).toISOString())}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
function ToolPlaceholder({ name }: { name: string }) {
return (
<div className="workdesk">

View File

@@ -621,3 +621,701 @@
.preview-details strong {
color: var(--color-foreground);
}
/* ============ Network Monitor ============ */
.network-controls {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.network-list {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
max-height: 400px;
overflow-y: auto;
}
.network-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--color-surface-0);
border-radius: var(--radius-md);
border-left: 3px solid var(--color-border);
font-size: var(--font-size-sm);
}
.network-item.pending { border-left-color: var(--color-warning); }
.network-item.success { border-left-color: var(--color-success); }
.network-item.error { border-left-color: var(--color-error); }
.network-url {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-foreground);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
}
.network-status,
.network-duration,
.network-size,
.network-time {
font-size: var(--font-size-xs);
color: var(--color-muted-foreground);
white-space: nowrap;
}
.network-status { min-width: 50px; }
.network-duration { min-width: 60px; }
.network-size { min-width: 50px; }
.network-time { min-width: 70px; }
/* ============ MCP Tools ============ */
.mcp-tools-list {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
max-height: 300px;
overflow-y: auto;
}
.mcp-tool-item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3);
background-color: var(--color-surface-0);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
cursor: pointer;
transition: all var(--duration-fast) var(--timing-out);
}
.mcp-tool-item:hover {
background-color: var(--color-surface-1);
border-color: var(--color-ring);
}
.mcp-tool-item.selected {
border-color: var(--color-primary);
background-color: var(--color-surface-1);
}
.mcp-tool-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-0-5);
}
.mcp-tool-name {
font-weight: var(--font-weight-medium);
color: var(--color-foreground);
}
.mcp-tool-desc {
font-size: var(--font-size-xs);
color: var(--color-muted-foreground);
}
.mcp-tool-params {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.mcp-result {
padding: var(--spacing-4);
background-color: var(--color-surface-2);
border-radius: var(--radius-md);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.tools-count {
font-size: var(--font-size-sm);
color: var(--color-muted-foreground);
margin-left: var(--spacing-3);
}
/* ============ Asset List ============ */
.asset-controls {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.select-sm {
height: 32px;
padding: 0 var(--spacing-2);
font-size: var(--font-size-sm);
background-color: var(--color-surface-0);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-foreground);
}
.assets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-4);
}
.asset-item {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-3);
background-color: var(--color-surface-0);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.asset-preview {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background-color: var(--color-surface-1);
border-radius: var(--radius-sm);
}
.asset-icon {
font-size: var(--font-size-3xl);
color: var(--color-muted-foreground);
}
.asset-info {
display: flex;
flex-direction: column;
gap: var(--spacing-0-5);
}
.asset-name {
font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
}
.asset-meta {
font-size: var(--font-size-xs);
color: var(--color-muted-foreground);
}
/* ============ Component List ============ */
.components-list {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.component-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-3);
background-color: var(--color-surface-0);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.component-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-0-5);
}
.component-name {
font-weight: var(--font-weight-medium);
}
.component-desc {
font-size: var(--font-size-xs);
color: var(--color-muted-foreground);
}
.component-meta {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.component-variants {
font-size: var(--font-size-xs);
color: var(--color-muted-foreground);
}
/* ============ Loading State ============ */
.workdesk-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-4);
min-height: 300px;
color: var(--color-muted-foreground);
}
/* ============ Connection Status ============ */
.connection-status {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
background-color: var(--color-surface-1);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
/* ============ Code Preview ============ */
.code-preview {
padding: var(--spacing-4);
background-color: var(--color-surface-2);
border-radius: var(--radius-md);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* ============ Token Drift ============ */
.drift-list {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drift-item {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-3);
background-color: var(--color-surface-0);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.drift-info {
display: flex;
flex-direction: column;
gap: var(--spacing-0-5);
}
.drift-token {
font-weight: var(--font-weight-medium);
}
.drift-file {
font-size: var(--font-size-xs);
color: var(--color-muted-foreground);
font-family: var(--font-family-mono);
}
.drift-values {
display: flex;
gap: var(--spacing-4);
font-size: var(--font-size-sm);
}
.drift-expected,
.drift-actual {
padding: var(--spacing-1) var(--spacing-2);
background-color: var(--color-surface-1);
border-radius: var(--radius-sm);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
}
.drift-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
/* ============ Health Overview ============ */
.health-overview {
display: flex;
align-items: center;
justify-content: space-between;
}
.health-timestamp {
font-size: var(--font-size-sm);
color: var(--color-muted-foreground);
}
.service-status-indicator[data-status="warning"] {
background-color: var(--color-warning);
}
/* ============ Alerts ============ */
.alert {
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-4);
}
.alert-success {
background-color: hsla(142, 76%, 36%, 0.1);
border: 1px solid var(--color-success);
}
.alert-error {
background-color: hsla(0, 84%, 60%, 0.1);
border: 1px solid var(--color-error);
}
/* ============ Form Error ============ */
.form-error {
margin-top: var(--spacing-3);
}
/* ============ Responsive ============ */
@media (max-width: 768px) {
.workdesk {
gap: var(--spacing-4);
}
.workdesk-title {
font-size: var(--font-size-xl);
}
.metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
.metric-value {
font-size: var(--font-size-2xl);
}
.quick-actions-grid {
flex-direction: column;
}
.quick-actions-grid .btn {
width: 100%;
}
.figma-file-item,
.esre-item,
.project-item,
.component-item {
flex-direction: column;
align-items: flex-start;
}
.figma-file-status,
.esre-actions,
.project-actions,
.component-meta {
margin-top: var(--spacing-2);
}
.integrations-grid {
grid-template-columns: 1fr;
}
.cache-stats {
grid-template-columns: 1fr;
}
.audit-controls,
.console-controls,
.network-controls,
.asset-controls {
flex-wrap: wrap;
}
.audit-table {
font-size: var(--font-size-xs);
}
.audit-table th,
.audit-table td {
padding: var(--spacing-2);
}
.network-item {
flex-wrap: wrap;
}
.network-url {
flex-basis: 100%;
order: 1;
margin-top: var(--spacing-1);
}
.assets-grid {
grid-template-columns: repeat(2, 1fr);
}
.drift-values {
flex-direction: column;
gap: var(--spacing-2);
}
}
@media (max-width: 480px) {
.metrics-grid {
grid-template-columns: 1fr;
}
.assets-grid {
grid-template-columns: 1fr;
}
.test-item {
flex-direction: column;
align-items: flex-start;
}
.test-status {
margin-top: var(--spacing-2);
}
}
/* ===========================================
Live Canvas Tool Styles
=========================================== */
.live-canvas-workdesk {
max-width: 100%;
}
/* Prompt Area */
.canvas-prompt-area {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.canvas-prompt-input {
width: 100%;
min-height: 80px;
padding: var(--spacing-3);
font-family: inherit;
font-size: var(--font-size-base);
background-color: var(--color-surface-0);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-foreground);
resize: vertical;
transition: border-color 0.15s ease;
}
.canvas-prompt-input:focus {
outline: none;
border-color: var(--color-primary);
}
.canvas-prompt-input::placeholder {
color: var(--color-muted-foreground);
}
.canvas-prompt-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.canvas-prompt-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-3);
}
.canvas-hint {
font-size: var(--font-size-sm);
color: var(--color-muted-foreground);
}
.canvas-error {
margin-top: var(--spacing-2);
}
/* Canvas Preview Card */
.canvas-preview-card {
flex: 1;
}
.canvas-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.viewport-switcher {
display: flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1);
background-color: var(--color-surface-1);
border-radius: var(--radius-md);
}
.viewport-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--color-muted-foreground);
cursor: pointer;
transition: all 0.15s ease;
}
.viewport-btn:hover {
background-color: var(--color-surface-2);
color: var(--color-foreground);
}
.viewport-btn.active {
background-color: var(--color-primary);
color: var(--color-primary-foreground);
}
/* Canvas Frame */
.canvas-frame-container {
display: flex;
justify-content: center;
padding: var(--spacing-4);
background-color: var(--color-surface-1);
border-radius: var(--radius-md);
min-height: 400px;
}
.canvas-frame {
background-color: #ffffff;
border-radius: var(--radius-md);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
overflow: hidden;
}
.canvas-iframe {
width: 100%;
height: 500px;
border: none;
display: block;
}
.canvas-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-8);
text-align: center;
min-height: 400px;
color: var(--color-muted-foreground);
}
.canvas-placeholder-icon {
font-size: 48px;
margin-bottom: var(--spacing-4);
}
.canvas-placeholder p {
margin: var(--spacing-1) 0;
}
/* Code Preview */
.canvas-code-preview {
max-height: 300px;
overflow: auto;
padding: var(--spacing-4);
background-color: var(--color-surface-1);
border-radius: var(--radius-md);
font-family: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
font-size: var(--font-size-sm);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.canvas-code-preview code {
color: var(--color-foreground);
}
/* History */
.canvas-history {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.canvas-history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-3);
background-color: var(--color-surface-0);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
}
.canvas-history-item:hover {
border-color: var(--color-primary);
background-color: var(--color-surface-1);
}
.history-prompt {
font-size: var(--font-size-sm);
color: var(--color-foreground);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-time {
font-size: var(--font-size-xs);
color: var(--color-muted-foreground);
margin-left: var(--spacing-3);
white-space: nowrap;
}
/* Responsive */
@media (max-width: 768px) {
.canvas-toolbar {
flex-wrap: wrap;
}
.canvas-prompt-actions {
flex-direction: column;
align-items: stretch;
}
.canvas-hint {
text-align: center;
}
.canvas-frame-container {
padding: var(--spacing-2);
}
.canvas-iframe {
height: 350px;
}
}