auto-backup: 2025-12-11 20:35:05 (68 files: +19 ~23 -25)
Generated by DSS Git Backup Hook
This commit is contained in:
@@ -27,9 +27,9 @@ admin-ui/src/
|
||||
│ ├── client.ts # API client with all endpoints
|
||||
│ └── types.ts # TypeScript interfaces
|
||||
├── components/
|
||||
│ ├── base/ # Button, Card, Input, Badge, Spinner
|
||||
│ ├── base/ # Button, Card, Input, Badge, Spinner, Skeleton
|
||||
│ ├── layout/ # Shell, Header, Sidebar, Panel, ChatSidebar
|
||||
│ └── shared/ # CommandPalette, Toast
|
||||
│ └── shared/ # CommandPalette, Toast, Modal, ErrorBoundary, DataTable
|
||||
├── workdesks/ # Team-specific views
|
||||
│ ├── UIWorkdesk.tsx # Figma extraction, code generation
|
||||
│ ├── UXWorkdesk.tsx # Token list, Figma files
|
||||
@@ -170,8 +170,10 @@ endpoints.mcp.status() // GET /api/mcp/status
|
||||
|
||||
**Tools**:
|
||||
- `dashboard` - Figma connection status, file sync status
|
||||
- `token-list` - View tokens from Figma
|
||||
- `figma-files` - Manage connected Figma files
|
||||
- `token-list` - View tokens from Figma
|
||||
- `asset-list` - Gallery of design assets (icons, images, illustrations)
|
||||
- `component-list` - Design system components
|
||||
- `figma-plugin` - Plugin installation info
|
||||
|
||||
### QA Team (`QAWorkdesk.tsx`)
|
||||
@@ -179,8 +181,10 @@ endpoints.mcp.status() // GET /api/mcp/status
|
||||
|
||||
**Tools**:
|
||||
- `dashboard` - Health score, ESRE definitions count
|
||||
- `figma-live-compare` - QA validation: Figma vs live implementation
|
||||
- `esre-editor` - Create/edit/delete ESRE definitions
|
||||
- `console-viewer` - Browser console log capture
|
||||
- `network-monitor` - Track network requests in real-time
|
||||
- `test-results` - View ESRE test results
|
||||
|
||||
### Admin (`AdminWorkdesk.tsx`)
|
||||
@@ -190,6 +194,7 @@ endpoints.mcp.status() // GET /api/mcp/status
|
||||
- `settings` - Server config, Figma token, Storybook URL
|
||||
- `projects` - CRUD for projects
|
||||
- `integrations` - External service connections
|
||||
- `mcp-tools` - View and execute MCP tools for AI assistants
|
||||
- `audit-log` - System activity with filtering/export
|
||||
- `cache-management` - Clear/purge cache
|
||||
- `health-monitor` - Service status with auto-refresh
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
141
admin-ui/src/components/base/Skeleton.css
Normal file
141
admin-ui/src/components/base/Skeleton.css
Normal 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;
|
||||
}
|
||||
106
admin-ui/src/components/base/Skeleton.tsx
Normal file
106
admin-ui/src/components/base/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
admin-ui/src/components/shared/DataTable.css
Normal file
164
admin-ui/src/components/shared/DataTable.css
Normal 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;
|
||||
}
|
||||
}
|
||||
249
admin-ui/src/components/shared/DataTable.tsx
Normal file
249
admin-ui/src/components/shared/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
admin-ui/src/components/shared/ErrorBoundary.css
Normal file
63
admin-ui/src/components/shared/ErrorBoundary.css
Normal 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);
|
||||
}
|
||||
104
admin-ui/src/components/shared/ErrorBoundary.tsx
Normal file
104
admin-ui/src/components/shared/ErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
165
admin-ui/src/components/shared/Modal.css
Normal file
165
admin-ui/src/components/shared/Modal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
152
admin-ui/src/components/shared/Modal.tsx
Normal file
152
admin-ui/src/components/shared/Modal.tsx
Normal 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 };
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +57,11 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // Bind to all interfaces for nginx proxy compatibility
|
||||
port: 3456,
|
||||
port: 6221, // DSS Admin UI port
|
||||
allowedHosts: ['dss.overbits.luz.uy', 'localhost', '.localhost'], // Allow external domain and localhost
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8002',
|
||||
target: 'http://localhost:6220', // DSS API port
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user