admin-ui: align with API, add auth, fix integrations
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
This commit is contained in:
@@ -23,7 +23,8 @@ class ApiClient {
|
||||
data?: unknown,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
// Most endpoints live under `/api/*`, but health is intentionally exposed at `/health`.
|
||||
const url = path === '/health' ? path : `${this.baseUrl}${path}`;
|
||||
|
||||
// Deduplicate GET requests
|
||||
if (method === 'GET') {
|
||||
@@ -58,6 +59,14 @@ class ApiClient {
|
||||
...options?.headers
|
||||
};
|
||||
|
||||
// Attach auth token when present (headless server auth).
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('dss-token');
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const config: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
@@ -124,35 +133,64 @@ export const api = new ApiClient();
|
||||
|
||||
// Import types
|
||||
import type {
|
||||
Project, ProjectCreateData, ProjectConfig,
|
||||
FigmaFile, FigmaExtractResult, FigmaHealthStatus,
|
||||
AuthLoginRequest,
|
||||
AuthLoginResponse,
|
||||
UserProfile,
|
||||
Project,
|
||||
ProjectCreateData,
|
||||
ProjectUpdateData,
|
||||
ProjectConfig,
|
||||
ProjectDashboardSummary,
|
||||
FigmaFile,
|
||||
FigmaFileCreateData,
|
||||
FigmaHealthStatus,
|
||||
FigmaExtractTokensResult,
|
||||
FigmaExtractComponentsResult,
|
||||
FigmaExtractStylesResult,
|
||||
FigmaSyncTokensResult,
|
||||
FigmaValidateResult,
|
||||
FigmaGenerateCodeResult,
|
||||
TokenDrift,
|
||||
TokenDriftListResponse,
|
||||
Component,
|
||||
ESREDefinition, ESRECreateData,
|
||||
AuditEntry, AuditStats,
|
||||
SystemHealth, RuntimeConfig,
|
||||
DiscoveryResult, DiscoveryStats,
|
||||
Team, TeamCreateData,
|
||||
Integration, IntegrationCreateData,
|
||||
ChatResponse, MCPTool,
|
||||
Service
|
||||
ESREDefinition,
|
||||
ESRECreateData,
|
||||
AuditLogResponse,
|
||||
AuditStats,
|
||||
RuntimeConfigEnvelope,
|
||||
ConfigUpdateRequest,
|
||||
FigmaConfigStatus,
|
||||
FigmaConnectionTestResult,
|
||||
SystemHealth,
|
||||
ServiceDiscoveryEnvelope,
|
||||
MCPIntegrationsResponse,
|
||||
ProjectIntegrationsResponse,
|
||||
ChatResponse,
|
||||
MCPTool,
|
||||
MCPStatus
|
||||
} from './types';
|
||||
|
||||
// Type-safe API endpoints - Mapped to actual DSS backend
|
||||
export const endpoints = {
|
||||
// Auth
|
||||
auth: {
|
||||
login: (data: AuthLoginRequest) => api.post<AuthLoginResponse>('/auth/login', data),
|
||||
me: () => api.get<UserProfile>('/auth/me')
|
||||
},
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
list: (status?: string) =>
|
||||
api.get<Project[]>(status ? `/projects?status=${status}` : '/projects'),
|
||||
get: (id: string) => api.get<Project>(`/projects/${id}`),
|
||||
create: (data: ProjectCreateData) => api.post<Project>('/projects', data),
|
||||
update: (id: string, data: Partial<Project>) => api.put<Project>(`/projects/${id}`, data),
|
||||
update: (id: string, data: ProjectUpdateData) => api.put<Project>(`/projects/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/projects/${id}`),
|
||||
config: (id: string) => api.get<ProjectConfig>(`/projects/${id}/config`),
|
||||
updateConfig: (id: string, data: ProjectConfig) => api.put<ProjectConfig>(`/projects/${id}/config`, data),
|
||||
context: (id: string) => api.get<unknown>(`/projects/${id}/context`),
|
||||
components: (id: string) => api.get<Component[]>(`/projects/${id}/components`),
|
||||
dashboard: (id: string) => api.get<unknown>(`/projects/${id}/dashboard/summary`),
|
||||
dashboard: (id: string) => api.get<ProjectDashboardSummary>(`/projects/${id}/dashboard/summary`),
|
||||
// Files (sandboxed)
|
||||
files: (id: string, path?: string) =>
|
||||
api.get<unknown>(`/projects/${id}/files${path ? `?path=${encodeURIComponent(path)}` : ''}`),
|
||||
@@ -163,7 +201,7 @@ export const endpoints = {
|
||||
api.post<void>(`/projects/${id}/files/write`, { path, content }),
|
||||
// Figma files per project
|
||||
figmaFiles: (id: string) => api.get<FigmaFile[]>(`/projects/${id}/figma-files`),
|
||||
addFigmaFile: (id: string, data: { name: string; fileKey: string }) =>
|
||||
addFigmaFile: (id: string, data: FigmaFileCreateData) =>
|
||||
api.post<FigmaFile>(`/projects/${id}/figma-files`, data),
|
||||
syncFigmaFile: (id: string, fileId: string) =>
|
||||
api.put<FigmaFile>(`/projects/${id}/figma-files/${fileId}/sync`, {}),
|
||||
@@ -174,23 +212,26 @@ export const endpoints = {
|
||||
// Figma Integration
|
||||
figma: {
|
||||
health: () => api.get<FigmaHealthStatus>('/figma/health'),
|
||||
extractVariables: (fileKey: string, nodeId?: string) =>
|
||||
api.post<FigmaExtractResult>('/figma/extract-variables', { file_key: fileKey, node_id: nodeId }),
|
||||
extractComponents: (fileKey: string, nodeId?: string) =>
|
||||
api.post<FigmaExtractResult>('/figma/extract-components', { file_key: fileKey, node_id: nodeId }),
|
||||
extractStyles: (fileKey: string, nodeId?: string) =>
|
||||
api.post<FigmaExtractResult>('/figma/extract-styles', { file_key: fileKey, node_id: nodeId }),
|
||||
extractVariables: (fileKey: string, format: string = 'css') =>
|
||||
api.post<FigmaExtractTokensResult>('/figma/extract-variables', { file_key: fileKey, format }),
|
||||
extractComponents: (fileKey: string) =>
|
||||
api.post<FigmaExtractComponentsResult>('/figma/extract-components', { file_key: fileKey, format: 'json' }),
|
||||
extractStyles: (fileKey: string) =>
|
||||
api.post<FigmaExtractStylesResult>('/figma/extract-styles', { file_key: fileKey, format: 'json' }),
|
||||
syncTokens: (fileKey: string, targetPath: string, format?: string) =>
|
||||
api.post<{ synced: number }>('/figma/sync-tokens', { file_key: fileKey, target_path: targetPath, format }),
|
||||
validate: (componentDef: unknown) =>
|
||||
api.post<{ valid: boolean; errors: string[] }>('/figma/validate', componentDef),
|
||||
generateCode: (componentDef: unknown, framework?: string) =>
|
||||
api.post<{ code: string }>('/figma/generate-code', { component: componentDef, framework })
|
||||
api.post<FigmaSyncTokensResult>('/figma/sync-tokens', { file_key: fileKey, target_path: targetPath, format }),
|
||||
validateComponents: (fileKey: string) =>
|
||||
api.post<FigmaValidateResult>('/figma/validate', { file_key: fileKey, format: 'json' }),
|
||||
generateCode: (fileKey: string, componentName: string, framework: string = 'webcomponent') =>
|
||||
api.post<FigmaGenerateCodeResult>(
|
||||
`/figma/generate-code?file_key=${encodeURIComponent(fileKey)}&component_name=${encodeURIComponent(componentName)}&framework=${encodeURIComponent(framework)}`,
|
||||
{}
|
||||
)
|
||||
},
|
||||
|
||||
// Token Drift
|
||||
tokens: {
|
||||
drift: (projectId: string) => api.get<TokenDrift[]>(`/projects/${projectId}/token-drift`),
|
||||
drift: (projectId: string) => api.get<TokenDriftListResponse>(`/projects/${projectId}/token-drift`),
|
||||
recordDrift: (projectId: string, data: Partial<TokenDrift>) =>
|
||||
api.post<TokenDrift>(`/projects/${projectId}/token-drift`, data),
|
||||
updateDriftStatus: (projectId: string, driftId: string, status: string) =>
|
||||
@@ -208,22 +249,11 @@ export const endpoints = {
|
||||
api.delete<void>(`/projects/${projectId}/esre/${esreId}`)
|
||||
},
|
||||
|
||||
// Integrations per project
|
||||
integrations: {
|
||||
list: (projectId: string) => api.get<Integration[]>(`/projects/${projectId}/integrations`),
|
||||
create: (projectId: string, data: IntegrationCreateData) =>
|
||||
api.post<Integration>(`/projects/${projectId}/integrations`, data),
|
||||
update: (projectId: string, type: string, data: Partial<Integration>) =>
|
||||
api.put<Integration>(`/projects/${projectId}/integrations/${type}`, data),
|
||||
delete: (projectId: string, type: string) =>
|
||||
api.delete<void>(`/projects/${projectId}/integrations/${type}`)
|
||||
},
|
||||
|
||||
// Discovery
|
||||
discovery: {
|
||||
run: () => api.get<DiscoveryResult>('/discovery'),
|
||||
scan: () => api.post<DiscoveryResult>('/discovery/scan', {}),
|
||||
stats: () => api.get<DiscoveryStats>('/discovery/stats'),
|
||||
run: () => api.get<unknown>('/discovery'),
|
||||
scan: () => api.post<unknown>('/discovery/scan', {}),
|
||||
stats: () => api.get<unknown>('/discovery/stats'),
|
||||
activity: () => api.get<unknown[]>('/discovery/activity'),
|
||||
ports: () => api.get<unknown[]>('/discovery/ports'),
|
||||
env: () => api.get<unknown>('/discovery/env')
|
||||
@@ -231,20 +261,20 @@ export const endpoints = {
|
||||
|
||||
// Teams
|
||||
teams: {
|
||||
list: () => api.get<Team[]>('/teams'),
|
||||
get: (id: string) => api.get<Team>(`/teams/${id}`),
|
||||
create: (data: TeamCreateData) => api.post<Team>('/teams', data)
|
||||
list: () => api.get<unknown[]>('/teams'),
|
||||
get: (id: string) => api.get<unknown>(`/teams/${id}`),
|
||||
create: (data: Record<string, unknown>) => api.post<unknown>('/teams', data)
|
||||
},
|
||||
|
||||
// System & Config
|
||||
system: {
|
||||
health: () => api.get<SystemHealth>('/health'),
|
||||
stats: () => api.get<unknown>('/stats'),
|
||||
config: () => api.get<RuntimeConfig>('/config'),
|
||||
updateConfig: (data: Partial<RuntimeConfig>) => api.put<RuntimeConfig>('/config', data),
|
||||
figmaConfig: () => api.get<{ configured: boolean }>('/config/figma'),
|
||||
testFigma: () => api.post<{ success: boolean; message: string }>('/config/figma/test', {}),
|
||||
reset: () => api.post<void>('/system/reset', {}),
|
||||
config: () => api.get<RuntimeConfigEnvelope>('/config'),
|
||||
updateConfig: (data: ConfigUpdateRequest) => api.put<Record<string, unknown>>('/config', data),
|
||||
figmaConfig: () => api.get<FigmaConfigStatus>('/config/figma'),
|
||||
testFigma: () => api.post<FigmaConnectionTestResult>('/config/figma/test', {}),
|
||||
reset: (confirm: 'RESET') => api.post<void>('/system/reset', { confirm }),
|
||||
mode: () => api.get<{ mode: string }>('/mode'),
|
||||
setMode: (mode: string) => api.put<{ mode: string }>('/mode', { mode })
|
||||
},
|
||||
@@ -252,15 +282,15 @@ export const endpoints = {
|
||||
// Cache Management
|
||||
cache: {
|
||||
clear: () => api.post<{ cleared: number }>('/cache/clear', {}),
|
||||
purge: () => api.delete<{ purged: number }>('/cache')
|
||||
purge: () => api.delete<{ success: boolean }>('/cache')
|
||||
},
|
||||
|
||||
// Services
|
||||
services: {
|
||||
list: () => api.get<Service[]>('/services'),
|
||||
configure: (name: string, config: unknown) => api.put<Service>(`/services/${name}`, config),
|
||||
list: () => api.get<ServiceDiscoveryEnvelope>('/services'),
|
||||
configure: (name: string, config: unknown) => api.put<Record<string, unknown>>(`/services/${name}`, config),
|
||||
storybook: () => api.get<{ running: boolean; url?: string }>('/services/storybook'),
|
||||
initStorybook: () => api.post<void>('/storybook/init', {}),
|
||||
initStorybook: (projectId?: string) => api.post<void>('/storybook/init', projectId ? { project_id: projectId } : {}),
|
||||
clearStories: () => api.delete<void>('/storybook/stories')
|
||||
},
|
||||
|
||||
@@ -268,9 +298,9 @@ export const endpoints = {
|
||||
audit: {
|
||||
list: (params?: Record<string, string>) => {
|
||||
const query = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
return api.get<AuditEntry[]>(`/audit${query}`);
|
||||
return api.get<AuditLogResponse>(`/audit${query}`);
|
||||
},
|
||||
create: (entry: Partial<AuditEntry>) => api.post<AuditEntry>('/audit', entry),
|
||||
create: (entry: Record<string, unknown>) => api.post<{ success: boolean; message: string }>('/audit', entry),
|
||||
stats: () => api.get<AuditStats>('/audit/stats'),
|
||||
categories: () => api.get<string[]>('/audit/categories'),
|
||||
actions: () => api.get<string[]>('/audit/actions'),
|
||||
@@ -285,17 +315,59 @@ export const endpoints = {
|
||||
|
||||
// Claude AI Chat
|
||||
claude: {
|
||||
chat: (message: string, projectId?: string, context?: unknown) =>
|
||||
api.post<ChatResponse>('/claude/chat', { message, project_id: projectId, context })
|
||||
chat: (
|
||||
message: string,
|
||||
projectId?: string,
|
||||
context?: unknown,
|
||||
options?: { user_id?: number; model?: 'claude' | 'gemini'; enable_tools?: boolean; history?: unknown[] }
|
||||
) =>
|
||||
api.post<ChatResponse>('/claude/chat', {
|
||||
message,
|
||||
project_id: projectId,
|
||||
user_id: options?.user_id,
|
||||
model: options?.model,
|
||||
enable_tools: options?.enable_tools,
|
||||
history: options?.history,
|
||||
context
|
||||
})
|
||||
},
|
||||
|
||||
// MCP Tools
|
||||
mcp: {
|
||||
integrations: () => api.get<string[]>('/mcp/integrations'),
|
||||
integrations: () => api.get<MCPIntegrationsResponse>('/mcp/integrations'),
|
||||
projectIntegrations: (projectId: string, userId?: number) =>
|
||||
api.get<ProjectIntegrationsResponse>(
|
||||
`/projects/${projectId}/integrations${typeof userId === 'number' ? `?user_id=${userId}` : ''}`
|
||||
),
|
||||
upsertProjectIntegration: (
|
||||
projectId: string,
|
||||
userId: number,
|
||||
integration_type: string,
|
||||
config: Record<string, unknown>,
|
||||
enabled: boolean = true
|
||||
) =>
|
||||
api.post<Record<string, unknown>>(
|
||||
`/projects/${projectId}/integrations?user_id=${userId}`,
|
||||
{ integration_type, config, enabled }
|
||||
),
|
||||
updateProjectIntegration: (
|
||||
projectId: string,
|
||||
userId: number,
|
||||
integration_type: string,
|
||||
update: { config?: Record<string, unknown>; enabled?: boolean }
|
||||
) =>
|
||||
api.put<Record<string, unknown>>(
|
||||
`/projects/${projectId}/integrations/${integration_type}?user_id=${userId}`,
|
||||
update
|
||||
),
|
||||
deleteProjectIntegration: (projectId: string, userId: number, integration_type: string) =>
|
||||
api.delete<Record<string, unknown>>(
|
||||
`/projects/${projectId}/integrations/${integration_type}?user_id=${userId}`
|
||||
),
|
||||
tools: () => api.get<MCPTool[]>('/mcp/tools'),
|
||||
tool: (name: string) => api.get<MCPTool>(`/mcp/tools/${name}`),
|
||||
execute: (name: string, params: unknown) => api.post<unknown>(`/mcp/tools/${name}/execute`, params),
|
||||
status: () => api.get<{ connected: boolean; tools: number }>('/mcp/status')
|
||||
status: () => api.get<MCPStatus>('/mcp/status')
|
||||
},
|
||||
|
||||
// Browser Logs
|
||||
@@ -307,7 +379,7 @@ export const endpoints = {
|
||||
// Debug
|
||||
debug: {
|
||||
diagnostic: () => api.get<unknown>('/debug/diagnostic'),
|
||||
workflows: () => api.get<unknown[]>('/debug/workflows')
|
||||
workflows: () => api.get<Record<string, unknown>>('/debug/workflows')
|
||||
},
|
||||
|
||||
// Design System Ingestion
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
// DSS Admin UI - API Types
|
||||
//
|
||||
// These types match the headless server in `apps/api/server.py`.
|
||||
|
||||
// ============ Auth ============
|
||||
|
||||
export type AtlassianService = 'jira' | 'confluence';
|
||||
|
||||
export interface AuthLoginRequest {
|
||||
url: string;
|
||||
email: string;
|
||||
api_token: string;
|
||||
service?: AtlassianService;
|
||||
}
|
||||
|
||||
export interface AuthLoginResponse {
|
||||
token: string;
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
display_name: string;
|
||||
atlassian_url?: string;
|
||||
service?: AtlassianService;
|
||||
};
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
email: string;
|
||||
display_name: string;
|
||||
atlassian_url?: string;
|
||||
atlassian_service?: AtlassianService;
|
||||
created_at?: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
// ============ Projects ============
|
||||
|
||||
@@ -6,127 +41,201 @@ export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
status: 'active' | 'archived' | 'draft';
|
||||
figma_file_key?: string;
|
||||
root_path?: string;
|
||||
status: 'active' | 'archived' | 'draft' | string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
components_count?: number;
|
||||
tokens_count?: number;
|
||||
}
|
||||
|
||||
export interface ProjectCreateData {
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
figma_file_key?: string;
|
||||
root_path?: string;
|
||||
}
|
||||
|
||||
export interface ProjectUpdateData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
figma_file_key?: string;
|
||||
root_path?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// `.dss/config.json` (managed by `ConfigService`)
|
||||
export interface ProjectConfig {
|
||||
schema_version?: string;
|
||||
figma?: {
|
||||
file_key?: string;
|
||||
access_token?: string;
|
||||
};
|
||||
storybook?: {
|
||||
url?: string;
|
||||
config_path?: string;
|
||||
file_id?: string | null;
|
||||
team_id?: string | null;
|
||||
};
|
||||
tokens?: {
|
||||
source?: string;
|
||||
output?: string;
|
||||
format?: 'css' | 'scss' | 'json' | 'js';
|
||||
output_path?: string;
|
||||
format?: 'css' | 'scss' | 'json' | 'js' | string;
|
||||
};
|
||||
components?: {
|
||||
source_dir?: string;
|
||||
patterns?: string[];
|
||||
ai?: {
|
||||
allowed_operations?: string[];
|
||||
context_files?: string[];
|
||||
max_file_size_kb?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProjectDashboardSummary {
|
||||
project_id: string;
|
||||
ux: {
|
||||
figma_files_count: number;
|
||||
figma_files: unknown[];
|
||||
};
|
||||
ui: {
|
||||
token_drift: TokenDriftStats;
|
||||
code_metrics: unknown;
|
||||
};
|
||||
qa: {
|
||||
esre_count: number;
|
||||
test_summary: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Figma ============
|
||||
|
||||
export interface FigmaHealthStatus {
|
||||
status: 'ok' | 'degraded' | string;
|
||||
mode: 'live' | 'mock' | string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FigmaToken {
|
||||
name: string;
|
||||
value: unknown;
|
||||
type: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface FigmaExtractTokensResult {
|
||||
success: boolean;
|
||||
tokens_count: number;
|
||||
collections: string[];
|
||||
output_path: string;
|
||||
tokens: FigmaToken[];
|
||||
formatted_output: string;
|
||||
}
|
||||
|
||||
export interface FigmaComponentDefinition {
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
properties: Record<string, unknown>;
|
||||
variants: unknown[];
|
||||
}
|
||||
|
||||
export interface FigmaExtractComponentsResult {
|
||||
success: boolean;
|
||||
components_count: number;
|
||||
output_path?: string;
|
||||
components: FigmaComponentDefinition[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FigmaStyleDefinition {
|
||||
name: string;
|
||||
key: string;
|
||||
type: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FigmaExtractStylesResult {
|
||||
success: boolean;
|
||||
styles_count: number;
|
||||
output_path?: string;
|
||||
styles: FigmaStyleDefinition[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FigmaSyncTokensResult {
|
||||
success: boolean;
|
||||
has_changes: boolean;
|
||||
tokens_synced: number;
|
||||
target_path: string;
|
||||
backup_created: boolean;
|
||||
}
|
||||
|
||||
export interface FigmaValidateResult {
|
||||
success: boolean;
|
||||
valid: boolean;
|
||||
components_checked: number;
|
||||
issues: Array<Record<string, unknown>>;
|
||||
summary: { errors: number; warnings: number; info: number };
|
||||
}
|
||||
|
||||
export interface FigmaGenerateCodeResult {
|
||||
success: boolean;
|
||||
component: string;
|
||||
framework: string;
|
||||
output_path?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Per-project saved Figma files (UX dashboard)
|
||||
export interface FigmaFile {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
figma_url: string;
|
||||
file_name: string;
|
||||
file_key: string;
|
||||
status: 'synced' | 'pending' | 'error';
|
||||
last_synced?: string;
|
||||
sync_status: 'pending' | 'synced' | 'error' | string;
|
||||
last_synced?: string | null;
|
||||
created_at: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FigmaHealthStatus {
|
||||
connected: boolean;
|
||||
token_configured: boolean;
|
||||
last_request?: string;
|
||||
rate_limit?: {
|
||||
remaining: number;
|
||||
reset_at: string;
|
||||
};
|
||||
export interface FigmaFileCreateData {
|
||||
figma_url: string;
|
||||
file_name: string;
|
||||
file_key: string;
|
||||
}
|
||||
|
||||
export interface FigmaExtractResult {
|
||||
success: boolean;
|
||||
count: number;
|
||||
items: FigmaExtractedItem[];
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface FigmaExtractedItem {
|
||||
name: string;
|
||||
type: string;
|
||||
value: unknown;
|
||||
description?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============ Tokens ============
|
||||
|
||||
export interface Token {
|
||||
name: string;
|
||||
value: string;
|
||||
type: 'color' | 'spacing' | 'typography' | 'radius' | 'shadow' | 'other';
|
||||
category?: string;
|
||||
description?: string;
|
||||
source?: 'figma' | 'code' | 'manual';
|
||||
}
|
||||
|
||||
export interface TokenDrift {
|
||||
id: string;
|
||||
project_id: string;
|
||||
token_name: string;
|
||||
expected_value: string;
|
||||
actual_value: string;
|
||||
file_path: string;
|
||||
line_number?: number;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'open' | 'resolved' | 'ignored';
|
||||
created_at: string;
|
||||
resolved_at?: string;
|
||||
}
|
||||
|
||||
// ============ Components ============
|
||||
// ============ Components (stored) ============
|
||||
|
||||
export interface Component {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
figma_key?: string;
|
||||
description?: string;
|
||||
file_path: string;
|
||||
type: 'react' | 'vue' | 'web-component' | 'other';
|
||||
props?: ComponentProp[];
|
||||
variants?: string[];
|
||||
has_story?: boolean;
|
||||
figma_node_id?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
variants?: unknown[];
|
||||
code_generated?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ComponentProp {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
default_value?: unknown;
|
||||
description?: string;
|
||||
// ============ Tokens / Drift ============
|
||||
|
||||
export interface TokenDrift {
|
||||
id: string;
|
||||
component_id: string;
|
||||
property_name: string;
|
||||
hardcoded_value: string;
|
||||
suggested_token?: string | null;
|
||||
severity: 'info' | 'warning' | 'critical' | string;
|
||||
file_path: string;
|
||||
line_number: number;
|
||||
status: 'pending' | 'fixed' | 'ignored' | string;
|
||||
detected_at: string;
|
||||
}
|
||||
|
||||
export interface TokenDriftStats {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
by_severity: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface TokenDriftListResponse {
|
||||
drifts: TokenDrift[];
|
||||
stats: TokenDriftStats;
|
||||
}
|
||||
|
||||
// ============ ESRE (QA) ============
|
||||
@@ -135,154 +244,137 @@ export interface ESREDefinition {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
component_name: string;
|
||||
description?: string;
|
||||
expected_value: string;
|
||||
selector?: string;
|
||||
css_property?: string;
|
||||
status: 'active' | 'inactive';
|
||||
last_result?: 'pass' | 'fail' | 'skip';
|
||||
last_tested?: string;
|
||||
definition_text: string;
|
||||
expected_value?: string | null;
|
||||
component_name?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ESRECreateData {
|
||||
name: string;
|
||||
component_name: string;
|
||||
description?: string;
|
||||
expected_value: string;
|
||||
selector?: string;
|
||||
css_property?: string;
|
||||
definition_text: string;
|
||||
expected_value?: string | null;
|
||||
component_name?: string | null;
|
||||
}
|
||||
|
||||
// ============ Audit ============
|
||||
// ============ Activity / Audit ============
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
category: string;
|
||||
action: string;
|
||||
user?: string;
|
||||
category?: string;
|
||||
severity?: string;
|
||||
description?: string;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
entity_name?: string;
|
||||
project_id?: string;
|
||||
details?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
team_context?: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
activities: AuditEntry[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface AuditStats {
|
||||
total_entries: number;
|
||||
by_category: Record<string, number>;
|
||||
by_action: Record<string, number>;
|
||||
recent_activity: number;
|
||||
by_user: Record<string, number>;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
// ============ System ============
|
||||
// ============ System / Runtime Config ============
|
||||
|
||||
export interface RuntimeConfigEnvelope {
|
||||
config: Record<string, unknown>;
|
||||
env: Record<string, unknown>;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface ConfigUpdateRequest {
|
||||
mode?: 'local' | 'server';
|
||||
figma_token?: string;
|
||||
services?: Record<string, unknown>;
|
||||
features?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface FigmaConfigStatus {
|
||||
configured: boolean;
|
||||
mode: string;
|
||||
features: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface FigmaConnectionTestResult {
|
||||
success: boolean;
|
||||
user?: string;
|
||||
handle?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SystemResetRequest {
|
||||
confirm: 'RESET';
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy' | string;
|
||||
uptime_seconds: number;
|
||||
version: string;
|
||||
timestamp: string;
|
||||
services: {
|
||||
storage: 'up' | 'down';
|
||||
mcp: 'up' | 'down';
|
||||
figma: 'up' | 'down' | 'not_configured';
|
||||
storage: string;
|
||||
mcp: string;
|
||||
figma: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
server_host: string;
|
||||
server_port: number;
|
||||
figma_token?: string;
|
||||
storybook_url?: string;
|
||||
data_dir?: string;
|
||||
log_level?: 'debug' | 'info' | 'warning' | 'error';
|
||||
export interface MCPStatus {
|
||||
connected: boolean;
|
||||
tools: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CacheStatus {
|
||||
size: number;
|
||||
entries: number;
|
||||
hit_rate: number;
|
||||
last_cleared?: string;
|
||||
export interface ServiceDiscoveryEnvelope {
|
||||
configured: Record<string, unknown>;
|
||||
discovered: Record<string, unknown>;
|
||||
storybook: { running: boolean; url?: string };
|
||||
}
|
||||
|
||||
// ============ Discovery ============
|
||||
// ============ MCP Integrations ============
|
||||
|
||||
export interface DiscoveryResult {
|
||||
projects: DiscoveredProject[];
|
||||
services: DiscoveredService[];
|
||||
timestamp: string;
|
||||
export interface MCPIntegrationHealth {
|
||||
integration_type: string;
|
||||
is_healthy: boolean;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
export interface DiscoveredProject {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
has_dss_config: boolean;
|
||||
framework?: string;
|
||||
export interface MCPIntegrationsResponse {
|
||||
integrations: MCPIntegrationHealth[];
|
||||
}
|
||||
|
||||
export interface DiscoveredService {
|
||||
name: string;
|
||||
port: number;
|
||||
type: string;
|
||||
url?: string;
|
||||
status: 'running' | 'stopped';
|
||||
}
|
||||
|
||||
export interface DiscoveryStats {
|
||||
projects_discovered: number;
|
||||
services_running: number;
|
||||
last_scan: string;
|
||||
}
|
||||
|
||||
// ============ Teams ============
|
||||
|
||||
export interface Team {
|
||||
export interface ProjectIntegrationRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
members?: TeamMember[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
|
||||
export interface TeamCreateData {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ============ Integrations ============
|
||||
|
||||
export interface Integration {
|
||||
type: 'figma' | 'jira' | 'confluence' | 'github' | 'gitlab' | 'slack';
|
||||
project_id: string;
|
||||
status: 'connected' | 'disconnected' | 'error';
|
||||
config: Record<string, unknown>;
|
||||
last_sync?: string;
|
||||
error_message?: string;
|
||||
user_id: number;
|
||||
integration_type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_used_at?: string | null;
|
||||
}
|
||||
|
||||
export interface IntegrationCreateData {
|
||||
type: Integration['type'];
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============ Services ============
|
||||
|
||||
export interface Service {
|
||||
name: string;
|
||||
type: string;
|
||||
url?: string;
|
||||
port?: number;
|
||||
status: 'running' | 'stopped' | 'unknown';
|
||||
config?: Record<string, unknown>;
|
||||
export interface ProjectIntegrationsResponse {
|
||||
integrations: ProjectIntegrationRecord[];
|
||||
}
|
||||
|
||||
// ============ Chat / Claude ============
|
||||
|
||||
@@ -33,6 +33,22 @@
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.chat-model-select {
|
||||
appearance: none;
|
||||
background-color: var(--color-surface-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-model-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-ring);
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.chat-quick-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
import { chatSidebarOpen } from '../../state/app';
|
||||
import { currentProject } from '../../state/project';
|
||||
import { activeTeam } from '../../state/team';
|
||||
import { userId as authenticatedUserId } from '../../state/user';
|
||||
import { endpoints } from '../../api/client';
|
||||
import { Button } from '../base/Button';
|
||||
import { Badge } from '../base/Badge';
|
||||
@@ -30,6 +31,7 @@ export function ChatSidebar() {
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [mcpTools, setMcpTools] = useState<string[]>([]);
|
||||
const [model, setModel] = useState<'claude' | 'gemini'>('claude');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -74,7 +76,9 @@ export function ChatSidebar() {
|
||||
project: currentProject.value ? {
|
||||
id: currentProject.value.id,
|
||||
name: currentProject.value.name,
|
||||
path: currentProject.value.path
|
||||
root_path: currentProject.value.root_path,
|
||||
// Backwards compatible field for older tool prompts.
|
||||
path: currentProject.value.root_path
|
||||
} : null,
|
||||
currentView: window.location.hash
|
||||
};
|
||||
@@ -82,7 +86,8 @@ export function ChatSidebar() {
|
||||
const response = await endpoints.claude.chat(
|
||||
userMessage.content,
|
||||
currentProject.value?.id,
|
||||
context
|
||||
context,
|
||||
{ user_id: authenticatedUserId.value, model }
|
||||
);
|
||||
|
||||
const assistantMessage: Message = {
|
||||
@@ -146,6 +151,15 @@ export function ChatSidebar() {
|
||||
<Badge variant="success" size="sm">{mcpTools.length} tools</Badge>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
className="chat-model-select"
|
||||
value={model}
|
||||
onChange={(e) => setModel((e.target as HTMLSelectElement).value as 'claude' | 'gemini')}
|
||||
aria-label="AI model"
|
||||
>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
</select>
|
||||
<div className="chat-header-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -33,6 +33,16 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* User */
|
||||
.header-user {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted-foreground);
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.header-logo {
|
||||
display: flex;
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
} from '../../state/app';
|
||||
import { activeTeam, setActiveTeam, TEAM_CONFIGS, TeamId } from '../../state/team';
|
||||
import { currentProject, projects, setCurrentProject } from '../../state/project';
|
||||
import { isAuthenticated, loginWithAtlassian, logout, user } from '../../state/user';
|
||||
import { Button } from '../base/Button';
|
||||
import { Badge } from '../base/Badge';
|
||||
import { Input, Select } from '../base/Input';
|
||||
import { Modal } from '../shared/Modal';
|
||||
import { useState } from 'preact/hooks';
|
||||
import './Header.css';
|
||||
|
||||
// Icons as inline SVG
|
||||
@@ -48,6 +53,15 @@ const MoonIcon = () => (
|
||||
|
||||
export function Header() {
|
||||
const teamKeys = Object.keys(TEAM_CONFIGS) as TeamId[];
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [credentials, setCredentials] = useState({
|
||||
url: '',
|
||||
email: '',
|
||||
api_token: '',
|
||||
service: 'jira' as 'jira' | 'confluence'
|
||||
});
|
||||
|
||||
const cycleTheme = () => {
|
||||
const themes = ['light', 'dark', 'auto'] as const;
|
||||
@@ -56,6 +70,20 @@ export function Header() {
|
||||
setTheme(themes[nextIndex]);
|
||||
};
|
||||
|
||||
async function handleLogin() {
|
||||
setLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await loginWithAtlassian(credentials);
|
||||
setLoginOpen(false);
|
||||
setCredentials({ url: '', email: '', api_token: '', service: 'jira' });
|
||||
} catch (err) {
|
||||
setLoginError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
@@ -105,6 +133,18 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
<div className="header-right">
|
||||
{/* Auth */}
|
||||
{isAuthenticated.value ? (
|
||||
<>
|
||||
<span className="header-user">
|
||||
{user.value?.display_name || user.value?.email || 'User'}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={logout}>Logout</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={() => setLoginOpen(true)}>Login</Button>
|
||||
)}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -125,6 +165,66 @@ export function Header() {
|
||||
AI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={loginOpen}
|
||||
onClose={() => { setLoginOpen(false); setLoginError(null); }}
|
||||
title="Login"
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => { setLoginOpen(false); setLoginError(null); }} disabled={loggingIn}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleLogin} loading={loggingIn}>
|
||||
Login
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="settings-form">
|
||||
{loginError && (
|
||||
<div className="form-error">
|
||||
<Badge variant="error">{loginError}</Badge>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Atlassian URL"
|
||||
value={credentials.url}
|
||||
onChange={(e) => setCredentials(c => ({ ...c, url: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="https://your-domain.atlassian.net"
|
||||
fullWidth
|
||||
/>
|
||||
<Select
|
||||
label="Service"
|
||||
value={credentials.service}
|
||||
onChange={(e) => setCredentials(c => ({ ...c, service: (e.target as HTMLSelectElement).value as 'jira' | 'confluence' }))}
|
||||
options={[
|
||||
{ value: 'jira', label: 'Jira' },
|
||||
{ value: 'confluence', label: 'Confluence' }
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials(c => ({ ...c, email: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="you@company.com"
|
||||
fullWidth
|
||||
/>
|
||||
<Input
|
||||
label="API Token"
|
||||
type="password"
|
||||
value={credentials.api_token}
|
||||
onChange={(e) => setCredentials(c => ({ ...c, api_token: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="••••••••••••"
|
||||
fullWidth
|
||||
/>
|
||||
<p className="text-muted" style={{ margin: 0 }}>
|
||||
This creates a DSS user and stores a session token in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import { Spinner } from '../base/Spinner';
|
||||
import { activeTeam, TEAM_CONFIGS, TeamId } from '../../state/team';
|
||||
import './Stage.css';
|
||||
|
||||
// Lazy load workdesks
|
||||
@@ -12,6 +13,13 @@ interface StageProps {
|
||||
activeTool: string | null;
|
||||
}
|
||||
|
||||
const WORKDESK_BY_TEAM: Record<TeamId, any> = {
|
||||
ui: UIWorkdesk,
|
||||
ux: UXWorkdesk,
|
||||
qa: QAWorkdesk,
|
||||
admin: AdminWorkdesk
|
||||
};
|
||||
|
||||
// Loading fallback
|
||||
function StageLoader() {
|
||||
return (
|
||||
@@ -22,55 +30,31 @@ function StageLoader() {
|
||||
);
|
||||
}
|
||||
|
||||
// Tool component mapping
|
||||
function getToolComponent(toolId: string | null) {
|
||||
// For now, return placeholder based on tool category
|
||||
// Later we'll add specific tool components
|
||||
function findOwnerTeam(toolId: string): TeamId {
|
||||
if (toolId === 'dashboard') return activeTeam.value;
|
||||
|
||||
switch (toolId) {
|
||||
case 'dashboard':
|
||||
case 'figma-extraction':
|
||||
case 'figma-components':
|
||||
case 'storybook-figma-compare':
|
||||
case 'storybook-live-compare':
|
||||
case 'project-analysis':
|
||||
case 'quick-wins':
|
||||
case 'regression-testing':
|
||||
case 'code-generator':
|
||||
return <UIWorkdesk activeTool={toolId} />;
|
||||
const preferred = activeTeam.value;
|
||||
if (TEAM_CONFIGS[preferred].tools.some(t => t.id === toolId)) return preferred;
|
||||
|
||||
case 'figma-plugin':
|
||||
case 'token-list':
|
||||
case 'asset-list':
|
||||
case 'component-list':
|
||||
case 'navigation-demos':
|
||||
return <UXWorkdesk activeTool={toolId} />;
|
||||
|
||||
case 'figma-live-compare':
|
||||
case 'esre-editor':
|
||||
case 'console-viewer':
|
||||
case 'network-monitor':
|
||||
case 'error-tracker':
|
||||
return <QAWorkdesk activeTool={toolId} />;
|
||||
|
||||
case 'settings':
|
||||
case 'projects':
|
||||
case 'integrations':
|
||||
case 'audit-log':
|
||||
case 'cache-management':
|
||||
case 'health-monitor':
|
||||
return <AdminWorkdesk activeTool={toolId} />;
|
||||
|
||||
default:
|
||||
return <UIWorkdesk activeTool="dashboard" />;
|
||||
const teams = Object.keys(TEAM_CONFIGS) as TeamId[];
|
||||
for (const team of teams) {
|
||||
if (TEAM_CONFIGS[team].tools.some(t => t.id === toolId)) return team;
|
||||
}
|
||||
|
||||
return preferred;
|
||||
}
|
||||
|
||||
export function Stage({ activeTool }: StageProps) {
|
||||
const rawToolId = activeTool || 'dashboard';
|
||||
const parts = rawToolId.split('/').filter(Boolean);
|
||||
const toolId = (parts.length >= 2 && (parts[0] as TeamId) in TEAM_CONFIGS) ? parts[1] : rawToolId;
|
||||
const team = findOwnerTeam(toolId);
|
||||
const Workdesk = WORKDESK_BY_TEAM[team];
|
||||
|
||||
return (
|
||||
<main className="stage" role="main">
|
||||
<Suspense fallback={<StageLoader />}>
|
||||
{getToolComponent(activeTool)}
|
||||
<Workdesk activeTool={toolId} />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export * from './team';
|
||||
export * from './user';
|
||||
|
||||
import { loadProjects } from './project';
|
||||
import { loadUserPreferences } from './user';
|
||||
import { loadUserPreferences, refreshUser } from './user';
|
||||
|
||||
/**
|
||||
* Initialize the application state
|
||||
@@ -17,6 +17,9 @@ export async function initializeApp(): Promise<void> {
|
||||
// Load user preferences from localStorage
|
||||
loadUserPreferences();
|
||||
|
||||
// Restore session if token exists
|
||||
await refreshUser();
|
||||
|
||||
// Load projects from API
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import { signal, computed } from '@preact/signals';
|
||||
import { api } from '../api/client';
|
||||
|
||||
// Types
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
figmaFileKey?: string;
|
||||
storybookUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: 'active' | 'archived' | 'draft';
|
||||
}
|
||||
|
||||
export interface ProjectStats {
|
||||
components: number;
|
||||
tokens: number;
|
||||
figmaFiles: number;
|
||||
healthScore: number;
|
||||
}
|
||||
import { endpoints } from '../api/client';
|
||||
import type { Project, ProjectCreateData, ProjectDashboardSummary, ProjectUpdateData } from '../api/types';
|
||||
|
||||
// Project State Signals
|
||||
export const projects = signal<Project[]>([]);
|
||||
export const currentProjectId = signal<string | null>(null);
|
||||
export const projectStats = signal<ProjectStats | null>(null);
|
||||
export const projectDashboard = signal<ProjectDashboardSummary | null>(null);
|
||||
export const projectsLoading = signal(false);
|
||||
export const projectsError = signal<string | null>(null);
|
||||
|
||||
@@ -45,7 +26,7 @@ export async function loadProjects(): Promise<void> {
|
||||
projectsError.value = null;
|
||||
|
||||
try {
|
||||
const data = await api.get<Project[]>('/projects');
|
||||
const data = await endpoints.projects.list();
|
||||
projects.value = data;
|
||||
|
||||
// Set first project as current if none selected
|
||||
@@ -64,34 +45,34 @@ export async function setCurrentProject(projectId: string): Promise<void> {
|
||||
currentProjectId.value = projectId;
|
||||
localStorage.setItem('dss-current-project', projectId);
|
||||
|
||||
// Load project stats
|
||||
await loadProjectStats(projectId);
|
||||
// Load dashboard summary (optional)
|
||||
await loadProjectDashboard(projectId);
|
||||
}
|
||||
|
||||
export async function loadProjectStats(projectId: string): Promise<void> {
|
||||
export async function loadProjectDashboard(projectId: string): Promise<void> {
|
||||
try {
|
||||
const stats = await api.get<ProjectStats>(`/projects/${projectId}/stats`);
|
||||
projectStats.value = stats;
|
||||
const summary = await endpoints.projects.dashboard(projectId);
|
||||
projectDashboard.value = summary;
|
||||
} catch (error) {
|
||||
console.error('Failed to load project stats:', error);
|
||||
projectStats.value = null;
|
||||
console.error('Failed to load project dashboard:', error);
|
||||
projectDashboard.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(data: Partial<Project>): Promise<Project> {
|
||||
const project = await api.post<Project>('/projects', data);
|
||||
export async function createProject(data: ProjectCreateData): Promise<Project> {
|
||||
const project = await endpoints.projects.create(data);
|
||||
projects.value = [...projects.value, project];
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function updateProject(projectId: string, data: Partial<Project>): Promise<Project> {
|
||||
const updated = await api.put<Project>(`/projects/${projectId}`, data);
|
||||
export async function updateProject(projectId: string, data: ProjectUpdateData): Promise<Project> {
|
||||
const updated = await endpoints.projects.update(projectId, data);
|
||||
projects.value = projects.value.map(p => p.id === projectId ? updated : p);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}`);
|
||||
await endpoints.projects.delete(projectId);
|
||||
projects.value = projects.value.filter(p => p.id !== projectId);
|
||||
|
||||
// Clear current if deleted
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { signal, computed } from '@preact/signals';
|
||||
import { endpoints } from '../api/client';
|
||||
import type { AuthLoginRequest, AuthLoginResponse, UserProfile } from '../api/types';
|
||||
|
||||
// Types
|
||||
export interface UserPreferences {
|
||||
@@ -10,14 +12,6 @@ export interface UserPreferences {
|
||||
notificationsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
role: 'admin' | 'developer' | 'viewer';
|
||||
}
|
||||
|
||||
// Default preferences
|
||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
theme: 'auto',
|
||||
@@ -29,16 +23,16 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
};
|
||||
|
||||
// User State Signals
|
||||
export const user = signal<User | null>(null);
|
||||
export const user = signal<UserProfile | null>(null);
|
||||
export const preferences = signal<UserPreferences>(DEFAULT_PREFERENCES);
|
||||
|
||||
// Computed Values
|
||||
export const isAuthenticated = computed(() => user.value !== null);
|
||||
export const isAdmin = computed(() => user.value?.role === 'admin');
|
||||
export const userName = computed(() => user.value?.name ?? 'Guest');
|
||||
export const userId = computed(() => user.value?.id ?? 1);
|
||||
export const userName = computed(() => user.value?.display_name ?? user.value?.email ?? 'Guest');
|
||||
export const userInitials = computed(() => {
|
||||
const name = user.value?.name ?? 'Guest';
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
const name = user.value?.display_name ?? user.value?.email ?? 'Guest';
|
||||
return name.split(' ').filter(Boolean).map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
});
|
||||
|
||||
// Actions
|
||||
@@ -66,7 +60,7 @@ export function updatePreferences(updates: Partial<UserPreferences>): void {
|
||||
saveUserPreferences();
|
||||
}
|
||||
|
||||
export function setUser(userData: User | null): void {
|
||||
export function setUser(userData: UserProfile | null): void {
|
||||
user.value = userData;
|
||||
}
|
||||
|
||||
@@ -75,6 +69,40 @@ export function logout(): void {
|
||||
localStorage.removeItem('dss-token');
|
||||
}
|
||||
|
||||
export async function refreshUser(): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const token = localStorage.getItem('dss-token');
|
||||
if (!token) {
|
||||
user.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await endpoints.auth.me();
|
||||
user.value = profile;
|
||||
} catch {
|
||||
// Token is invalid/expired; clear it.
|
||||
localStorage.removeItem('dss-token');
|
||||
user.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithAtlassian(credentials: AuthLoginRequest): Promise<AuthLoginResponse> {
|
||||
const response = await endpoints.auth.login(credentials);
|
||||
localStorage.setItem('dss-token', response.token);
|
||||
// Prefer server truth; also supports older login response shapes.
|
||||
user.value = {
|
||||
id: response.user.id,
|
||||
email: response.user.email,
|
||||
display_name: response.user.display_name,
|
||||
atlassian_url: response.user.atlassian_url,
|
||||
atlassian_service: response.user.service,
|
||||
last_login: new Date().toISOString(),
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
// Keyboard shortcuts state
|
||||
export const keyboardShortcutsEnabled = computed(() =>
|
||||
preferences.value.keyboardShortcutsEnabled
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,24 +63,22 @@ function QADashboard() {
|
||||
|
||||
if (esreResult.status === 'fulfilled') {
|
||||
const esres = esreResult.value;
|
||||
const passed = esres.filter(e => e.last_result === 'pass').length;
|
||||
const total = esres.length;
|
||||
const healthScore = total > 0 ? Math.round((passed / total) * 100) : 100;
|
||||
|
||||
setMetrics({
|
||||
healthScore,
|
||||
healthScore: 100,
|
||||
esreDefinitions: total,
|
||||
testsRun: total,
|
||||
testsPassed: passed
|
||||
testsRun: 0,
|
||||
testsPassed: 0
|
||||
});
|
||||
}
|
||||
|
||||
if (auditResult.status === 'fulfilled') {
|
||||
const audits = auditResult.value as AuditEntry[];
|
||||
setRecentTests(audits.slice(0, 5).map((entry, idx) => ({
|
||||
const audits = auditResult.value.activities;
|
||||
setRecentTests(audits.slice(0, 5).map((entry: AuditEntry, idx: number) => ({
|
||||
id: idx,
|
||||
name: entry.action || 'Test',
|
||||
status: entry.details?.status as string || 'info',
|
||||
status: (entry.details?.status as string) || entry.severity || 'info',
|
||||
time: formatTimeAgo(entry.timestamp)
|
||||
})));
|
||||
}
|
||||
@@ -240,11 +238,9 @@ function ESREEditorTool() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState<ESRECreateData>({
|
||||
name: '',
|
||||
component_name: '',
|
||||
description: '',
|
||||
definition_text: '',
|
||||
expected_value: '',
|
||||
selector: '',
|
||||
css_property: ''
|
||||
component_name: ''
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -276,8 +272,8 @@ function ESREEditorTool() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name || !formData.component_name || !formData.expected_value) {
|
||||
setError('Name, Component, and Expected Value are required');
|
||||
if (!formData.name || !formData.definition_text) {
|
||||
setError('Name and Definition Text are required');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -285,14 +281,17 @@ function ESREEditorTool() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await endpoints.esre.create(projectId, formData);
|
||||
await endpoints.esre.create(projectId, {
|
||||
name: formData.name,
|
||||
definition_text: formData.definition_text,
|
||||
expected_value: formData.expected_value ? formData.expected_value : undefined,
|
||||
component_name: formData.component_name ? formData.component_name : undefined
|
||||
});
|
||||
setFormData({
|
||||
name: '',
|
||||
component_name: '',
|
||||
description: '',
|
||||
definition_text: '',
|
||||
expected_value: '',
|
||||
selector: '',
|
||||
css_property: ''
|
||||
component_name: ''
|
||||
});
|
||||
loadESRE();
|
||||
} catch (err) {
|
||||
@@ -348,39 +347,25 @@ function ESREEditorTool() {
|
||||
/>
|
||||
<Input
|
||||
label="Component Name"
|
||||
value={formData.component_name}
|
||||
value={formData.component_name || ''}
|
||||
onChange={(e) => setFormData(d => ({ ...d, component_name: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="e.g., Button"
|
||||
fullWidth
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(d => ({ ...d, description: (e.target as HTMLTextAreaElement).value }))}
|
||||
label="Definition Text"
|
||||
value={formData.definition_text}
|
||||
onChange={(e) => setFormData(d => ({ ...d, definition_text: (e.target as HTMLTextAreaElement).value }))}
|
||||
placeholder="Describe the expected behavior..."
|
||||
fullWidth
|
||||
/>
|
||||
<Input
|
||||
label="Expected Value"
|
||||
value={formData.expected_value}
|
||||
value={formData.expected_value || ''}
|
||||
onChange={(e) => setFormData(d => ({ ...d, expected_value: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="e.g., 4px or var(--radius-md)"
|
||||
fullWidth
|
||||
/>
|
||||
<Input
|
||||
label="CSS Selector (optional)"
|
||||
value={formData.selector}
|
||||
onChange={(e) => setFormData(d => ({ ...d, selector: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="e.g., .btn-primary"
|
||||
fullWidth
|
||||
/>
|
||||
<Input
|
||||
label="CSS Property (optional)"
|
||||
value={formData.css_property}
|
||||
onChange={(e) => setFormData(d => ({ ...d, css_property: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="e.g., border-radius"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="form-error">
|
||||
@@ -416,16 +401,6 @@ function ESREEditorTool() {
|
||||
<div className="esre-expected">
|
||||
<code>{esre.expected_value}</code>
|
||||
</div>
|
||||
<div className="esre-status">
|
||||
{esre.last_result && (
|
||||
<Badge
|
||||
variant={esre.last_result === 'pass' ? 'success' : esre.last_result === 'fail' ? 'error' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
{esre.last_result}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="esre-actions">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(esre.id)}>
|
||||
Delete
|
||||
@@ -758,9 +733,9 @@ function TestResultsTool() {
|
||||
setResults(esres.map(esre => ({
|
||||
id: esre.id,
|
||||
name: esre.name,
|
||||
status: esre.last_result || 'skip',
|
||||
status: 'skip',
|
||||
duration: 0,
|
||||
error: esre.last_result === 'fail' ? 'Expected value mismatch' : undefined
|
||||
error: undefined
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error('Failed to load results:', err);
|
||||
@@ -788,7 +763,7 @@ function TestResultsTool() {
|
||||
<div className="workdesk">
|
||||
<div className="workdesk-header">
|
||||
<h1 className="workdesk-title">Test Results</h1>
|
||||
<p className="workdesk-subtitle">ESRE test execution results</p>
|
||||
<p className="workdesk-subtitle">ESRE definitions (runner not implemented yet)</p>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
|
||||
@@ -7,7 +7,14 @@ import { Input, Select } from '../components/base/Input';
|
||||
import { Spinner } from '../components/base/Spinner';
|
||||
import { endpoints } from '../api/client';
|
||||
import { currentProject } from '../state/project';
|
||||
import type { TokenDrift, Component, FigmaExtractResult } from '../api/types';
|
||||
import type {
|
||||
Component,
|
||||
FigmaExtractComponentsResult,
|
||||
FigmaExtractTokensResult,
|
||||
FigmaHealthStatus,
|
||||
TokenDrift,
|
||||
TokenDriftStats,
|
||||
} from '../api/types';
|
||||
import './Workdesk.css';
|
||||
|
||||
interface UIWorkdeskProps {
|
||||
@@ -45,7 +52,7 @@ function UIDashboard() {
|
||||
time: string;
|
||||
status: string;
|
||||
}>>([]);
|
||||
const [figmaHealth, setFigmaHealth] = useState<{ connected: boolean } | null>(null);
|
||||
const [figmaHealth, setFigmaHealth] = useState<FigmaHealthStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
@@ -92,12 +99,12 @@ function UIDashboard() {
|
||||
}
|
||||
|
||||
if (driftResult.status === 'fulfilled') {
|
||||
const drifts = driftResult.value;
|
||||
const { stats } = driftResult.value;
|
||||
setMetrics(m => ({
|
||||
...m,
|
||||
tokenDrift: drifts.length,
|
||||
criticalIssues: drifts.filter(d => d.severity === 'critical').length,
|
||||
warnings: drifts.filter(d => d.severity === 'medium' || d.severity === 'low').length
|
||||
tokenDrift: stats.total,
|
||||
criticalIssues: stats.by_severity.critical || 0,
|
||||
warnings: (stats.by_severity.warning || 0) + (stats.by_severity.info || 0)
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -125,22 +132,24 @@ function UIDashboard() {
|
||||
|
||||
switch (actionId) {
|
||||
case 'extractTokens':
|
||||
window.location.hash = '#ui/figma-extraction';
|
||||
window.location.hash = '#figma-extraction';
|
||||
break;
|
||||
case 'extractComponents':
|
||||
window.location.hash = '#ui/figma-components';
|
||||
window.location.hash = '#figma-components';
|
||||
break;
|
||||
case 'generateCode':
|
||||
window.location.hash = '#ui/code-generator';
|
||||
window.location.hash = '#code-generator';
|
||||
break;
|
||||
case 'syncTokens':
|
||||
// Quick sync using default settings
|
||||
try {
|
||||
const fileKey = currentProject.value?.path;
|
||||
if (fileKey) {
|
||||
const fileKey = currentProject.value?.figma_file_key;
|
||||
if (!fileKey) {
|
||||
alert('No Figma file key configured for this project. Set it in Admin → Projects.');
|
||||
return;
|
||||
}
|
||||
await endpoints.figma.syncTokens(fileKey, './tokens.css', 'css');
|
||||
loadDashboardData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err);
|
||||
}
|
||||
@@ -176,9 +185,9 @@ function UIDashboard() {
|
||||
|
||||
{/* Figma Connection Status */}
|
||||
{figmaHealth && (
|
||||
<div className={`connection-status ${figmaHealth.connected ? 'connected' : 'disconnected'}`}>
|
||||
<Badge variant={figmaHealth.connected ? 'success' : 'error'} size="sm">
|
||||
Figma: {figmaHealth.connected ? 'Connected' : 'Not Connected'}
|
||||
<div className={`connection-status ${figmaHealth.status === 'ok' ? 'connected' : 'disconnected'}`}>
|
||||
<Badge variant={figmaHealth.status === 'ok' ? 'success' : 'warning'} size="sm" title={figmaHealth.message}>
|
||||
Figma: {figmaHealth.status} ({figmaHealth.mode})
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -263,12 +272,15 @@ function UIDashboard() {
|
||||
|
||||
function FigmaExtractionTool() {
|
||||
const [fileKey, setFileKey] = useState('');
|
||||
const [nodeId, setNodeId] = useState('');
|
||||
const [format, setFormat] = useState('css');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<FigmaExtractResult | null>(null);
|
||||
const [result, setResult] = useState<FigmaExtractTokensResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFileKey(currentProject.value?.figma_file_key || '');
|
||||
}, [currentProject.value?.id]);
|
||||
|
||||
async function handleExtract() {
|
||||
if (!fileKey.trim()) {
|
||||
setError('Please enter a Figma file key or URL');
|
||||
@@ -282,7 +294,7 @@ function FigmaExtractionTool() {
|
||||
try {
|
||||
// Extract file key from URL if needed
|
||||
const key = extractFigmaFileKey(fileKey);
|
||||
const extractResult = await endpoints.figma.extractVariables(key, nodeId || undefined);
|
||||
const extractResult = await endpoints.figma.extractVariables(key, format);
|
||||
setResult(extractResult);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraction failed');
|
||||
@@ -298,8 +310,8 @@ function FigmaExtractionTool() {
|
||||
try {
|
||||
const key = extractFigmaFileKey(fileKey);
|
||||
const targetPath = `./tokens.${format === 'scss' ? 'scss' : format === 'json' ? 'json' : 'css'}`;
|
||||
await endpoints.figma.syncTokens(key, targetPath, format);
|
||||
alert(`Tokens synced to ${targetPath}`);
|
||||
const syncResult = await endpoints.figma.syncTokens(key, targetPath, format);
|
||||
alert(`${syncResult.tokens_synced} tokens synced to ${targetPath}${syncResult.has_changes ? '' : ' (no changes)'}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sync failed');
|
||||
} finally {
|
||||
@@ -325,14 +337,6 @@ function FigmaExtractionTool() {
|
||||
placeholder="Enter Figma file key or paste URL"
|
||||
fullWidth
|
||||
/>
|
||||
<Input
|
||||
label="Node ID (optional)"
|
||||
value={nodeId}
|
||||
onChange={(e) => setNodeId((e.target as HTMLInputElement).value)}
|
||||
placeholder="Specific node ID to extract from"
|
||||
hint="Leave empty to extract from entire file"
|
||||
fullWidth
|
||||
/>
|
||||
<Select
|
||||
label="Output Format"
|
||||
value={format}
|
||||
@@ -368,22 +372,13 @@ function FigmaExtractionTool() {
|
||||
<Card variant="bordered" padding="md">
|
||||
<CardHeader
|
||||
title="Extraction Results"
|
||||
subtitle={`${result.count} tokens extracted`}
|
||||
subtitle={`${result.tokens_count} tokens extracted`}
|
||||
/>
|
||||
<CardContent>
|
||||
{result.warnings && result.warnings.length > 0 && (
|
||||
<div className="result-warnings">
|
||||
{result.warnings.map((warning, idx) => (
|
||||
<Badge key={idx} variant="warning" size="sm">{warning}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="tokens-preview">
|
||||
<pre className="code-preview">
|
||||
{result.items.slice(0, 10).map(item => (
|
||||
`${item.name}: ${JSON.stringify(item.value)}\n`
|
||||
)).join('')}
|
||||
{result.items.length > 10 && `\n... and ${result.items.length - 10} more`}
|
||||
{result.tokens.slice(0, 10).map(t => `${t.name}: ${JSON.stringify(t.value)}`).join('\n')}
|
||||
{result.tokens.length > 10 && `\n... and ${result.tokens.length - 10} more`}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -396,9 +391,13 @@ function FigmaExtractionTool() {
|
||||
function FigmaComponentsTool() {
|
||||
const [fileKey, setFileKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [components, setComponents] = useState<FigmaExtractResult | null>(null);
|
||||
const [components, setComponents] = useState<FigmaExtractComponentsResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFileKey(currentProject.value?.figma_file_key || '');
|
||||
}, [currentProject.value?.id]);
|
||||
|
||||
async function handleExtract() {
|
||||
if (!fileKey.trim()) {
|
||||
setError('Please enter a Figma file key or URL');
|
||||
@@ -456,14 +455,14 @@ function FigmaComponentsTool() {
|
||||
<Card variant="bordered" padding="md">
|
||||
<CardHeader
|
||||
title="Components Found"
|
||||
subtitle={`${components.count} components`}
|
||||
subtitle={`${components.components_count} components`}
|
||||
/>
|
||||
<CardContent>
|
||||
<div className="components-list">
|
||||
{components.items.map((comp, idx) => (
|
||||
<div key={idx} className="component-item">
|
||||
{components.components.map(comp => (
|
||||
<div key={comp.key} className="component-item">
|
||||
<span className="component-name">{comp.name}</span>
|
||||
<Badge size="sm">{comp.type}</Badge>
|
||||
<Badge size="sm">{comp.key}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -478,6 +477,7 @@ function CodeGeneratorTool() {
|
||||
const [components, setComponents] = useState<Component[]>([]);
|
||||
const [selectedComponent, setSelectedComponent] = useState('');
|
||||
const [framework, setFramework] = useState('react');
|
||||
const [fileKey, setFileKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generatedCode, setGeneratedCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -486,6 +486,10 @@ function CodeGeneratorTool() {
|
||||
loadComponents();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFileKey(currentProject.value?.figma_file_key || '');
|
||||
}, [currentProject.value?.id]);
|
||||
|
||||
async function loadComponents() {
|
||||
const projectId = currentProject.value?.id;
|
||||
if (!projectId) return;
|
||||
@@ -503,6 +507,10 @@ function CodeGeneratorTool() {
|
||||
setError('Please select a component');
|
||||
return;
|
||||
}
|
||||
if (!fileKey.trim()) {
|
||||
setError('Set a Figma file key (Admin → Projects) or enter one here.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -511,10 +519,7 @@ function CodeGeneratorTool() {
|
||||
const component = components.find(c => c.id === selectedComponent);
|
||||
if (!component) throw new Error('Component not found');
|
||||
|
||||
const result = await endpoints.figma.generateCode(
|
||||
{ name: component.name, figma_node_id: component.figma_node_id },
|
||||
framework
|
||||
);
|
||||
const result = await endpoints.figma.generateCode(fileKey, component.name, framework);
|
||||
setGeneratedCode(result.code);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Code generation failed');
|
||||
@@ -534,13 +539,20 @@ function CodeGeneratorTool() {
|
||||
<CardHeader title="Generate Component" />
|
||||
<CardContent>
|
||||
<div className="settings-form">
|
||||
<Input
|
||||
label="Figma File Key"
|
||||
value={fileKey}
|
||||
onChange={(e) => setFileKey((e.target as HTMLInputElement).value)}
|
||||
placeholder="Figma file key"
|
||||
fullWidth
|
||||
/>
|
||||
<Select
|
||||
label="Component"
|
||||
value={selectedComponent}
|
||||
onChange={(e) => setSelectedComponent((e.target as HTMLSelectElement).value)}
|
||||
options={[
|
||||
{ value: '', label: 'Select a component...' },
|
||||
...components.map(c => ({ value: c.id, label: c.display_name || c.name }))
|
||||
...components.map(c => ({ value: c.id, label: c.name }))
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
@@ -590,6 +602,7 @@ function CodeGeneratorTool() {
|
||||
|
||||
function TokenDriftTool() {
|
||||
const [drifts, setDrifts] = useState<TokenDrift[]>([]);
|
||||
const [stats, setStats] = useState<TokenDriftStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -605,7 +618,8 @@ function TokenDriftTool() {
|
||||
|
||||
try {
|
||||
const result = await endpoints.tokens.drift(projectId);
|
||||
setDrifts(result);
|
||||
setDrifts(result.drifts);
|
||||
setStats(result.stats);
|
||||
} catch (err) {
|
||||
console.error('Failed to load drifts:', err);
|
||||
} finally {
|
||||
@@ -618,8 +632,8 @@ function TokenDriftTool() {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
await endpoints.tokens.updateDriftStatus(projectId, driftId, 'resolved');
|
||||
loadDrifts();
|
||||
await endpoints.tokens.updateDriftStatus(projectId, driftId, 'fixed');
|
||||
void loadDrifts();
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve drift:', err);
|
||||
}
|
||||
@@ -646,7 +660,7 @@ function TokenDriftTool() {
|
||||
<Card variant="bordered" padding="md">
|
||||
<CardHeader
|
||||
title="Drift Issues"
|
||||
subtitle={`${drifts.length} issues found`}
|
||||
subtitle={`${stats?.total ?? drifts.length} issues found`}
|
||||
action={<Button variant="outline" size="sm" onClick={loadDrifts}>Refresh</Button>}
|
||||
/>
|
||||
<CardContent>
|
||||
@@ -657,23 +671,22 @@ function TokenDriftTool() {
|
||||
{drifts.map(drift => (
|
||||
<div key={drift.id} className="drift-item">
|
||||
<div className="drift-info">
|
||||
<span className="drift-token">{drift.token_name}</span>
|
||||
<span className="drift-file">{drift.file_path}</span>
|
||||
<span className="drift-token">{drift.component_id} • {drift.property_name}</span>
|
||||
<span className="drift-file">{drift.file_path}:{drift.line_number}</span>
|
||||
</div>
|
||||
<div className="drift-values">
|
||||
<code className="drift-expected">Expected: {drift.expected_value}</code>
|
||||
<code className="drift-actual">Actual: {drift.actual_value}</code>
|
||||
<code className="drift-expected">Hardcoded: {drift.hardcoded_value}</code>
|
||||
<code className="drift-actual">Suggested: {drift.suggested_token || '-'}</code>
|
||||
</div>
|
||||
<div className="drift-actions">
|
||||
<Badge variant={
|
||||
drift.severity === 'critical' ? 'error' :
|
||||
drift.severity === 'high' ? 'error' :
|
||||
drift.severity === 'medium' ? 'warning' : 'default'
|
||||
drift.severity === 'warning' ? 'warning' : 'default'
|
||||
} size="sm">
|
||||
{drift.severity}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleResolve(drift.id)}>
|
||||
Resolve
|
||||
Mark Fixed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -689,26 +702,69 @@ function TokenDriftTool() {
|
||||
function QuickWinsTool() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [quickWins, setQuickWins] = useState<Array<{
|
||||
id: number;
|
||||
id: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
effort: string;
|
||||
priority?: string;
|
||||
effort?: string;
|
||||
file?: string;
|
||||
}>>([]);
|
||||
const [rawResult, setRawResult] = useState<unknown | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function scanProject() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Use discovery scan to find quick wins
|
||||
setError(null);
|
||||
setRawResult(null);
|
||||
setQuickWins([]);
|
||||
|
||||
const projectId = currentProject.value?.id;
|
||||
if (!projectId) {
|
||||
setError('Select a project first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer an MCP tool (portable across environments); fall back to discovery scan only if needed.
|
||||
const tools = await endpoints.mcp.tools();
|
||||
const candidate = tools.find(t =>
|
||||
/quick|win/i.test(t.name) || /quick|win/i.test(t.description)
|
||||
);
|
||||
|
||||
if (!candidate) {
|
||||
await endpoints.discovery.scan();
|
||||
// Mock data for now - will be replaced with actual API response parsing
|
||||
setQuickWins([
|
||||
{ id: 1, title: 'Replace hardcoded color #333', priority: 'high', effort: 'low', file: 'Button.tsx' },
|
||||
{ id: 2, title: 'Use spacing token instead of 16px', priority: 'medium', effort: 'low', file: 'Card.tsx' },
|
||||
{ id: 3, title: 'Update font weight to token', priority: 'low', effort: 'low', file: 'Header.tsx' }
|
||||
]);
|
||||
setError('No MCP "quick wins" tool found; ran discovery scan instead.');
|
||||
return;
|
||||
}
|
||||
|
||||
type ExecResponse = { success: boolean; result?: unknown; error?: string };
|
||||
const exec = await endpoints.mcp.execute(candidate.name, { project_id: projectId, arguments: {} }) as ExecResponse;
|
||||
if (!exec.success) {
|
||||
throw new Error(exec.error || 'Quick wins tool failed');
|
||||
}
|
||||
|
||||
const result = exec.result;
|
||||
setRawResult(result ?? null);
|
||||
|
||||
const listCandidate =
|
||||
Array.isArray(result) ? result :
|
||||
(result && typeof result === 'object' && Array.isArray((result as Record<string, unknown>).quick_wins)) ? (result as Record<string, unknown>).quick_wins :
|
||||
(result && typeof result === 'object' && Array.isArray((result as Record<string, unknown>).items)) ? (result as Record<string, unknown>).items :
|
||||
null;
|
||||
|
||||
if (!Array.isArray(listCandidate)) return;
|
||||
|
||||
setQuickWins(listCandidate.map((item, idx) => {
|
||||
const obj = (item && typeof item === 'object') ? (item as Record<string, unknown>) : {};
|
||||
return {
|
||||
id: String(obj.id || idx),
|
||||
title: String(obj.title || obj.message || obj.name || 'Quick win'),
|
||||
priority: typeof obj.priority === 'string' ? obj.priority : undefined,
|
||||
effort: typeof obj.effort === 'string' ? obj.effort : undefined,
|
||||
file: typeof obj.file === 'string' ? obj.file : undefined,
|
||||
};
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Scan failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Scan failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -731,6 +787,11 @@ function QuickWinsTool() {
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="form-error">
|
||||
<Badge variant="error">{error}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{quickWins.length === 0 ? (
|
||||
<p className="text-muted">Click "Scan Project" to find quick wins</p>
|
||||
) : (
|
||||
@@ -742,20 +803,32 @@ function QuickWinsTool() {
|
||||
{win.file && <span className="quick-win-file">{win.file}</span>}
|
||||
</div>
|
||||
<div className="quick-win-badges">
|
||||
{win.priority && (
|
||||
<Badge
|
||||
variant={win.priority === 'high' ? 'error' : win.priority === 'medium' ? 'warning' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
{win.priority}
|
||||
</Badge>
|
||||
)}
|
||||
{win.effort && (
|
||||
<Badge variant="success" size="sm">
|
||||
{win.effort} effort
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rawResult && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<details>
|
||||
<summary className="text-muted">Raw tool output</summary>
|
||||
<pre className="code-preview">{JSON.stringify(rawResult, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from '../components/base/Input';
|
||||
import { Spinner } from '../components/base/Spinner';
|
||||
import { endpoints } from '../api/client';
|
||||
import { currentProject } from '../state/project';
|
||||
import type { FigmaFile } from '../api/types';
|
||||
import type { Component, FigmaFile, FigmaHealthStatus, FigmaStyleDefinition, FigmaToken } from '../api/types';
|
||||
import './Workdesk.css';
|
||||
|
||||
interface UXWorkdeskProps {
|
||||
@@ -34,7 +34,7 @@ export default function UXWorkdesk({ activeTool }: UXWorkdeskProps) {
|
||||
function UXDashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [figmaFiles, setFigmaFiles] = useState<FigmaFile[]>([]);
|
||||
const [figmaHealth, setFigmaHealth] = useState<{ connected: boolean } | null>(null);
|
||||
const [figmaHealth, setFigmaHealth] = useState<FigmaHealthStatus | null>(null);
|
||||
const [metrics, setMetrics] = useState({
|
||||
figmaFiles: 0,
|
||||
synced: 0,
|
||||
@@ -52,7 +52,7 @@ function UXDashboard() {
|
||||
const projectId = currentProject.value?.id;
|
||||
|
||||
// Load Figma health
|
||||
const healthResult = await endpoints.figma.health().catch(() => ({ connected: false }));
|
||||
const healthResult = await endpoints.figma.health().catch(() => null);
|
||||
setFigmaHealth(healthResult);
|
||||
|
||||
// Load project-specific data
|
||||
@@ -60,8 +60,8 @@ function UXDashboard() {
|
||||
const filesResult = await endpoints.projects.figmaFiles(projectId).catch(() => []);
|
||||
setFigmaFiles(filesResult);
|
||||
|
||||
const synced = filesResult.filter(f => f.status === 'synced').length;
|
||||
const pending = filesResult.filter(f => f.status === 'pending').length;
|
||||
const synced = filesResult.filter(f => f.sync_status === 'synced').length;
|
||||
const pending = filesResult.filter(f => f.sync_status === 'pending').length;
|
||||
|
||||
setMetrics({
|
||||
figmaFiles: filesResult.length,
|
||||
@@ -119,9 +119,9 @@ function UXDashboard() {
|
||||
|
||||
{/* Connection Status */}
|
||||
{figmaHealth && (
|
||||
<div className={`connection-status ${figmaHealth.connected ? 'connected' : 'disconnected'}`}>
|
||||
<Badge variant={figmaHealth.connected ? 'success' : 'error'} size="sm">
|
||||
Figma: {figmaHealth.connected ? 'Connected' : 'Not Connected'}
|
||||
<div className={`connection-status ${figmaHealth.status === 'ok' ? 'connected' : 'disconnected'}`}>
|
||||
<Badge variant={figmaHealth.status === 'ok' ? 'success' : 'warning'} size="sm" title={figmaHealth.message}>
|
||||
Figma: {figmaHealth.status} ({figmaHealth.mode})
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -173,15 +173,15 @@ function UXDashboard() {
|
||||
{figmaFiles.map(file => (
|
||||
<div key={file.id} className="figma-file-item">
|
||||
<div className="figma-file-info">
|
||||
<span className="figma-file-name">{file.name}</span>
|
||||
<span className="figma-file-name">{file.file_name}</span>
|
||||
<span className="figma-file-key">{file.file_key}</span>
|
||||
</div>
|
||||
<div className="figma-file-status">
|
||||
<Badge
|
||||
variant={file.status === 'synced' ? 'success' : file.status === 'error' ? 'error' : 'warning'}
|
||||
variant={file.sync_status === 'synced' ? 'success' : file.sync_status === 'error' ? 'error' : 'warning'}
|
||||
size="sm"
|
||||
>
|
||||
{file.status === 'synced' ? 'Synced' : file.status === 'error' ? 'Error' : 'Pending'}
|
||||
{file.sync_status === 'synced' ? 'Synced' : file.sync_status === 'error' ? 'Error' : 'Pending'}
|
||||
</Badge>
|
||||
{file.last_synced && (
|
||||
<span className="figma-file-sync">{formatTimeAgo(file.last_synced)}</span>
|
||||
@@ -201,7 +201,7 @@ function UXDashboard() {
|
||||
}
|
||||
|
||||
function AddFigmaFileCard({ onAdded }: { onAdded: () => void }) {
|
||||
const [formData, setFormData] = useState({ name: '', fileKey: '' });
|
||||
const [formData, setFormData] = useState({ fileName: '', figmaUrl: '' });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -212,8 +212,8 @@ function AddFigmaFileCard({ onAdded }: { onAdded: () => void }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name || !formData.fileKey) {
|
||||
setError('Name and File Key are required');
|
||||
if (!formData.fileName || !formData.figmaUrl) {
|
||||
setError('File Name and Figma URL/File Key are required');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,9 +222,18 @@ function AddFigmaFileCard({ onAdded }: { onAdded: () => void }) {
|
||||
|
||||
try {
|
||||
// Extract file key from URL if needed
|
||||
const fileKey = extractFigmaFileKey(formData.fileKey);
|
||||
await endpoints.projects.addFigmaFile(projectId, { name: formData.name, fileKey });
|
||||
setFormData({ name: '', fileKey: '' });
|
||||
const fileKey = extractFigmaFileKey(formData.figmaUrl);
|
||||
const figmaUrl = formData.figmaUrl.includes('figma.com')
|
||||
? formData.figmaUrl
|
||||
: `https://www.figma.com/file/${fileKey}`;
|
||||
|
||||
await endpoints.projects.addFigmaFile(projectId, {
|
||||
figma_url: figmaUrl,
|
||||
file_name: formData.fileName,
|
||||
file_key: fileKey
|
||||
});
|
||||
|
||||
setFormData({ fileName: '', figmaUrl: '' });
|
||||
onAdded();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add Figma file');
|
||||
@@ -240,15 +249,15 @@ function AddFigmaFileCard({ onAdded }: { onAdded: () => void }) {
|
||||
<form className="add-figma-form" onSubmit={(e) => { e.preventDefault(); handleAdd(); }}>
|
||||
<Input
|
||||
label="File Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(d => ({ ...d, name: (e.target as HTMLInputElement).value }))}
|
||||
value={formData.fileName}
|
||||
onChange={(e) => setFormData(d => ({ ...d, fileName: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="e.g., Design System"
|
||||
fullWidth
|
||||
/>
|
||||
<Input
|
||||
label="Figma URL or File Key"
|
||||
value={formData.fileKey}
|
||||
onChange={(e) => setFormData(d => ({ ...d, fileKey: (e.target as HTMLInputElement).value }))}
|
||||
value={formData.figmaUrl}
|
||||
onChange={(e) => setFormData(d => ({ ...d, figmaUrl: (e.target as HTMLInputElement).value }))}
|
||||
placeholder="https://figma.com/file/... or file key"
|
||||
fullWidth
|
||||
/>
|
||||
@@ -288,12 +297,12 @@ function TokenListTool() {
|
||||
if (projectId) {
|
||||
const files = await endpoints.projects.figmaFiles(projectId);
|
||||
if (files.length > 0) {
|
||||
const result = await endpoints.figma.extractVariables(files[0].file_key);
|
||||
setTokens(result.items.map(item => ({
|
||||
name: item.name,
|
||||
value: String(item.value),
|
||||
type: item.type,
|
||||
category: item.metadata?.category as string
|
||||
const result = await endpoints.figma.extractVariables(files[0].file_key, 'json');
|
||||
setTokens(result.tokens.map((token: FigmaToken) => ({
|
||||
name: token.name,
|
||||
value: typeof token.value === 'string' ? token.value : JSON.stringify(token.value),
|
||||
type: token.type,
|
||||
category: token.category
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -462,15 +471,15 @@ function FigmaFilesTool() {
|
||||
{files.map(file => (
|
||||
<div key={file.id} className="figma-file-item">
|
||||
<div className="figma-file-info">
|
||||
<span className="figma-file-name">{file.name}</span>
|
||||
<span className="figma-file-name">{file.file_name}</span>
|
||||
<span className="figma-file-key">{file.file_key}</span>
|
||||
</div>
|
||||
<div className="figma-file-status">
|
||||
<Badge
|
||||
variant={file.status === 'synced' ? 'success' : file.status === 'error' ? 'error' : 'warning'}
|
||||
variant={file.sync_status === 'synced' ? 'success' : file.sync_status === 'error' ? 'error' : 'warning'}
|
||||
size="sm"
|
||||
>
|
||||
{file.status}
|
||||
{file.sync_status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="figma-file-actions">
|
||||
@@ -522,13 +531,13 @@ function AssetListTool() {
|
||||
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,
|
||||
const extractedAssets = result.styles
|
||||
.filter((style: FigmaStyleDefinition) => style.type === 'effect' || style.type === 'paint')
|
||||
.map((style: FigmaStyleDefinition, idx: number) => ({
|
||||
id: style.key || String(idx),
|
||||
name: style.name,
|
||||
type: 'icon' as const,
|
||||
format: 'svg',
|
||||
format: 'figma-style',
|
||||
size: '-'
|
||||
}));
|
||||
setAssets(extractedAssets);
|
||||
@@ -643,12 +652,12 @@ function ComponentListTool() {
|
||||
const projectId = currentProject.value?.id;
|
||||
if (projectId) {
|
||||
const result = await endpoints.projects.components(projectId);
|
||||
setComponents(result.map(c => ({
|
||||
setComponents(result.map((c: Component) => ({
|
||||
id: c.id,
|
||||
name: c.display_name || c.name,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
variants: c.variants?.length || 0,
|
||||
status: (c as unknown as { status?: string }).status === 'active' ? 'ready' : 'in-progress'
|
||||
status: c.code_generated ? 'ready' : 'planned'
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -783,22 +783,22 @@ async def list_workflows():
|
||||
return {"workflows": workflows, "count": len(workflows), "directory": str(workflows_dir)}
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config():
|
||||
@app.get("/api/public-config")
|
||||
async def get_public_config():
|
||||
"""
|
||||
Public configuration endpoint.
|
||||
|
||||
Returns ONLY safe, non-sensitive configuration values that are safe
|
||||
to expose to the client browser.
|
||||
|
||||
SECURITY: This endpoint is the ONLY place where configuration is exposed.
|
||||
All other config values (secrets, API keys, etc.) must be server-only.
|
||||
NOTE: The Admin UI uses `/api/config` for runtime settings (with secrets masked).
|
||||
This endpoint is kept for backwards compatibility with older clients.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
try:
|
||||
from config import get_public_config
|
||||
from config import get_public_config as _get_public_config
|
||||
|
||||
return get_public_config()
|
||||
return _get_public_config()
|
||||
except ImportError:
|
||||
# Fallback for legacy deployments
|
||||
return {
|
||||
@@ -848,6 +848,7 @@ async def create_project(project: ProjectCreate):
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
figma_file_key=project.figma_file_key,
|
||||
root_path=project.root_path,
|
||||
)
|
||||
ActivityLog.log(
|
||||
action="project_created",
|
||||
|
||||
@@ -218,7 +218,13 @@ class Projects:
|
||||
(base / subdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def create(id: str, name: str, description: str = "", figma_file_key: str = "") -> Dict:
|
||||
def create(
|
||||
id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
figma_file_key: str = "",
|
||||
root_path: str = "",
|
||||
) -> Dict:
|
||||
"""Create a new project."""
|
||||
Projects._init_project_structure(id)
|
||||
|
||||
@@ -228,6 +234,7 @@ class Projects:
|
||||
"name": name,
|
||||
"description": description,
|
||||
"figma_file_key": figma_file_key,
|
||||
"root_path": root_path,
|
||||
"status": "active",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
|
||||
Reference in New Issue
Block a user