Initial commit: Clean DSS implementation

Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm

Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)

Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability

Migration completed: $(date)
🤖 Clean migration with full functionality preserved
This commit is contained in:
Digital Production Factory
2025-12-09 18:45:48 -03:00
commit 276ed71f31
884 changed files with 373737 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
/**
* admin-store.js
* Global admin settings store for DSS configuration
* Manages hostname, port, local/remote setup type
*/
export class AdminStore {
constructor() {
this.state = {
hostname: localStorage.getItem('admin_hostname') || 'localhost',
port: parseInt(localStorage.getItem('admin_port')) || 6006,
isRemote: localStorage.getItem('admin_isRemote') === 'true' || false,
dssSetupType: localStorage.getItem('admin_setupType') || 'local'
};
this.listeners = new Set();
}
getState() {
return { ...this.state };
}
setState(newState) {
const oldState = this.state;
this.state = { ...this.state, ...newState };
// Persist to localStorage
localStorage.setItem('admin_hostname', this.state.hostname);
localStorage.setItem('admin_port', this.state.port.toString());
localStorage.setItem('admin_isRemote', this.state.isRemote.toString());
localStorage.setItem('admin_setupType', this.state.dssSetupType);
// Notify listeners
this.notifyListeners();
return this.state;
}
/**
* Update hostname
* @param {string} hostname - Hostname or IP address
*/
setHostname(hostname) {
return this.setState({ hostname });
}
/**
* Update port
* @param {number} port - Port number
*/
setPort(port) {
return this.setState({ port: parseInt(port) });
}
/**
* Toggle between local and remote DSS setup
* @param {boolean} isRemote - True for remote, false for local
*/
setRemote(isRemote) {
const dssSetupType = isRemote ? 'remote' : 'local';
return this.setState({ isRemote, dssSetupType });
}
/**
* Get full URL for Storybook based on current settings
* @param {string} skin - Selected skin/theme
* @returns {string} Full Storybook URL
*/
getStorybookUrl(skin = 'default') {
const protocol = this.state.isRemote ? 'https' : 'http';
return `${protocol}://${this.state.hostname}:${this.state.port}/storybook/?skin=${skin}`;
}
/**
* Subscribe to state changes
* @param {Function} callback - Called when state changes
* @returns {Function} Unsubscribe function
*/
subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
notifyListeners() {
this.listeners.forEach(listener => listener(this.state));
}
/**
* Reset to defaults
*/
reset() {
localStorage.removeItem('admin_hostname');
localStorage.removeItem('admin_port');
localStorage.removeItem('admin_isRemote');
localStorage.removeItem('admin_setupType');
this.state = {
hostname: 'localhost',
port: 6006,
isRemote: false,
dssSetupType: 'local'
};
this.notifyListeners();
}
}
// Singleton instance
let adminStoreInstance = null;
export function useAdminStore() {
if (!adminStoreInstance) {
adminStoreInstance = new AdminStore();
}
return adminStoreInstance;
}

View File

@@ -0,0 +1,548 @@
/**
* Design System Server (DSS) - App Store
*
* Centralized state management with reactive subscriptions
* for managing application state across components and pages.
*/
class AppStore {
constructor() {
this.state = {
// User & Auth
user: null,
team: null,
role: null,
// Navigation
currentPage: 'dashboard',
currentProject: null,
sidebarOpen: true,
// Data
projects: [],
tokens: [],
components: [],
styles: [],
// Discovery
discovery: null,
health: null,
activity: [],
stats: null,
// Figma
figmaConnected: false,
figmaFileKey: null,
lastSync: null,
// Configuration (loaded from server /api/config)
serverConfig: null,
isConfigLoading: true,
configError: null,
// UI State
loading: {},
errors: {},
notifications: []
};
this.listeners = new Map();
this.middleware = [];
// Track in-flight requests to prevent duplicates
this.pendingRequests = new Map();
}
// === State Access ===
get(key) {
return key ? this.state[key] : this.state;
}
// === State Updates ===
set(updates, silent = false) {
const prevState = { ...this.state };
// Apply middleware
for (const mw of this.middleware) {
updates = mw(updates, prevState);
}
this.state = { ...this.state, ...updates };
if (!silent) {
this._notify(updates, prevState);
}
}
// === Subscriptions ===
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key).add(callback);
// Return unsubscribe function
return () => {
this.listeners.get(key)?.delete(callback);
};
}
subscribeAll(callback) {
return this.subscribe('*', callback);
}
_notify(updates, prevState) {
// Notify specific key listeners
for (const key of Object.keys(updates)) {
const listeners = this.listeners.get(key);
if (listeners) {
listeners.forEach(cb => cb(updates[key], prevState[key], key));
}
}
// Notify global listeners
const globalListeners = this.listeners.get('*');
if (globalListeners) {
globalListeners.forEach(cb => cb(this.state, prevState));
}
}
// === Middleware ===
use(middleware) {
this.middleware.push(middleware);
}
// === Loading State ===
setLoading(key, loading = true) {
this.set({
loading: { ...this.state.loading, [key]: loading }
});
}
isLoading(key) {
return this.state.loading[key] || false;
}
// === Error State ===
setError(key, error) {
this.set({
errors: { ...this.state.errors, [key]: error }
});
}
clearError(key) {
const errors = { ...this.state.errors };
delete errors[key];
this.set({ errors });
}
// === Notifications ===
notify(message, type = 'info', duration = 5000) {
const notification = {
id: Date.now(),
message,
type,
timestamp: new Date()
};
this.set({
notifications: [...this.state.notifications, notification]
});
if (duration > 0) {
setTimeout(() => this.dismissNotification(notification.id), duration);
}
return notification.id;
}
dismissNotification(id) {
this.set({
notifications: this.state.notifications.filter(n => n.id !== id)
});
}
// === User & Auth ===
setUser(user, team = null, role = null) {
this.set({ user, team, role });
}
logout() {
this.set({
user: null,
team: null,
role: null,
projects: [],
tokens: [],
components: [],
currentProject: null
});
localStorage.removeItem('currentProject');
}
hasPermission(permission) {
const rolePermissions = {
SUPER_ADMIN: ['*'],
TEAM_LEAD: ['read', 'write', 'sync', 'manage_team'],
DEVELOPER: ['read', 'write', 'sync'],
VIEWER: ['read']
};
const perms = rolePermissions[this.state.role] || [];
return perms.includes('*') || perms.includes(permission);
}
// === Projects ===
/**
* Fetch all projects from API
*/
async fetchProjects() {
this.setLoading('projects', true);
try {
const response = await fetch('/api/projects');
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const json = await response.json();
if (json.status === 'success') {
// Clear any previous errors on success
this.setError('projects', null);
this.setProjects(json.data.projects || []);
// Auto-restore last selected project if available
if (!this.state.currentProject && json.data.projects.length > 0) {
const stored = localStorage.getItem('currentProject');
if (stored) {
try {
const parsed = JSON.parse(stored);
const exists = json.data.projects.find(p => p.id === parsed.id);
if (exists) this.setProject(exists);
} catch (e) {
console.warn('Invalid stored project', e);
}
}
}
} else {
throw new Error(json.message || 'Failed to fetch projects');
}
} catch (error) {
console.error('Project fetch error:', error);
this.setError('projects', error.message);
} finally {
this.setLoading('projects', false);
}
}
setProjects(projects) {
this.set({ projects });
}
setProject(project) {
this.set({ currentProject: project });
if (project) {
localStorage.setItem('currentProject', JSON.stringify(project));
} else {
localStorage.removeItem('currentProject');
}
}
async getProjectConfig() {
if (!this.state.currentProject) return null;
this.setLoading('config', true);
try {
const response = await fetch(`/api/config/${this.state.currentProject.id}/resolved`);
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const json = await response.json();
if (json.status === 'success') {
// Clear any previous errors on success
this.setError('config', null);
return json.data.config;
} else {
throw new Error(json.message || 'Failed to fetch config');
}
} catch (error) {
console.error('Config fetch error:', error);
// Set error state so UI can display retry option
this.setError('config', error.message);
return null;
} finally {
this.setLoading('config', false);
}
}
/**
* Fetch all components from registry
* Includes request deduplication to prevent duplicate API calls
*/
async fetchComponents() {
const requestKey = 'components';
// Return existing promise if request is in flight
if (this.pendingRequests.has(requestKey)) {
return this.pendingRequests.get(requestKey);
}
// Create new request promise
const requestPromise = (async () => {
this.setLoading('components', true);
try {
const response = await fetch('/api/components');
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const json = await response.json();
if (json.status === 'success') {
this.setError('components', null);
this.setComponents(json.data.components || []);
} else {
throw new Error(json.message || 'Failed to fetch components');
}
} catch (error) {
console.error('Components fetch error:', error);
this.setError('components', error.message);
throw error;
} finally {
this.setLoading('components', false);
this.pendingRequests.delete(requestKey);
}
})();
this.pendingRequests.set(requestKey, requestPromise);
return requestPromise;
}
/**
* Fetch design tokens for a project
* Includes request deduplication to prevent duplicate API calls
*/
async fetchTokens(projectId) {
if (!projectId) {
console.warn('fetchTokens called without projectId');
return;
}
const requestKey = `tokens:${projectId}`;
// Return existing promise if request is in flight
if (this.pendingRequests.has(requestKey)) {
return this.pendingRequests.get(requestKey);
}
// Create new request promise
const requestPromise = (async () => {
this.setLoading('tokens', true);
try {
const response = await fetch(`/api/projects/${projectId}/tokens`);
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const json = await response.json();
if (json.status === 'success') {
this.setError('tokens', null);
this.setTokens(json.data.tokens || []);
} else {
throw new Error(json.message || 'Failed to fetch tokens');
}
} catch (error) {
console.error('Tokens fetch error:', error);
this.setError('tokens', error.message);
throw error;
} finally {
this.setLoading('tokens', false);
this.pendingRequests.delete(requestKey);
}
})();
this.pendingRequests.set(requestKey, requestPromise);
return requestPromise;
}
/**
* Fetch discovery scan results for a project
* Includes request deduplication to prevent duplicate API calls
*/
async fetchDiscoveryResults(projectId) {
if (!projectId) {
console.warn('fetchDiscoveryResults called without projectId');
return;
}
const requestKey = `discovery:${projectId}`;
// Return existing promise if request is in flight
if (this.pendingRequests.has(requestKey)) {
return this.pendingRequests.get(requestKey);
}
// Create new request promise
const requestPromise = (async () => {
this.setLoading('discovery', true);
try {
const response = await fetch(`/api/discovery/scan?projectId=${projectId}`);
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const json = await response.json();
if (json.status === 'success') {
this.setError('discovery', null);
this.setDiscovery(json.data || null);
} else {
throw new Error(json.message || 'Failed to fetch discovery results');
}
} catch (error) {
console.error('Discovery fetch error:', error);
this.setError('discovery', error.message);
throw error;
} finally {
this.setLoading('discovery', false);
this.pendingRequests.delete(requestKey);
}
})();
this.pendingRequests.set(requestKey, requestPromise);
return requestPromise;
}
addProject(project) {
this.set({
projects: [...this.state.projects, project]
});
}
updateProject(id, updates) {
this.set({
projects: this.state.projects.map(p =>
p.id === id ? { ...p, ...updates } : p
)
});
}
// === Figma ===
setFigmaConnected(connected, fileKey = null) {
this.set({
figmaConnected: connected,
figmaFileKey: fileKey
});
}
setLastSync(timestamp) {
this.set({ lastSync: timestamp });
}
// === Tokens ===
setTokens(tokens) {
this.set({ tokens });
}
getTokensByCategory(category) {
return this.state.tokens.filter(t => t.category === category);
}
// === Components ===
setComponents(components) {
this.set({ components });
}
// === Discovery ===
setDiscovery(discovery) {
this.set({ discovery });
}
setHealth(health) {
this.set({ health });
}
setActivity(activity) {
this.set({ activity });
}
setStats(stats) {
this.set({ stats });
}
// === Persistence ===
persist() {
const toPersist = {
user: this.state.user,
team: this.state.team,
role: this.state.role,
figmaFileKey: this.state.figmaFileKey,
sidebarOpen: this.state.sidebarOpen
// NOTE: serverConfig is loaded from /api/config, not persisted locally
};
localStorage.setItem('dss-store', JSON.stringify(toPersist));
}
hydrate() {
try {
// Hydrate general store data
const stored = localStorage.getItem('dss-store');
if (stored) {
const data = JSON.parse(stored);
this.set(data, true);
}
// Hydrate project context separately
const storedProject = localStorage.getItem('currentProject');
if (storedProject) {
const project = JSON.parse(storedProject);
this.set({ currentProject: project }, true);
}
} catch (e) {
console.warn('Failed to hydrate store:', e);
}
}
// === Debug ===
debug() {
console.group('App Store State');
console.log('State:', this.state);
console.log('Listeners:', Array.from(this.listeners.keys()));
console.groupEnd();
}
}
// Logging middleware
const loggingMiddleware = (updates, prevState) => {
if (window.DEBUG) {
console.log('[Store Update]', updates);
}
return updates;
};
// Create and export singleton
const store = new AppStore();
store.use(loggingMiddleware);
store.hydrate();
// Auto-persist on important changes
store.subscribe('user', () => store.persist());
store.subscribe('team', () => store.persist());
store.subscribe('figmaFileKey', () => store.persist());
// NOTE: serverConfig is NOT persisted - loaded fresh from /api/config on each init
export { AppStore };
export default store;

View File

@@ -0,0 +1,318 @@
/**
* @fileoverview A simple global store for application context.
* Manages shared state like current page, project, team, and selected items.
* Used by components like ds-ai-chat to be context-aware.
* MVP2: Extended with admin settings and project configuration context.
*
* ARCHITECTURE NOTE: Context store uses localStorage directly for persistence.
* Incognito mode handling is managed at the API client layer (api-client.js).
* This removes circular dependencies and initialization order issues.
*/
/**
* Page-specific context prompts for the AI assistant.
* These provide contextual awareness for each page in the admin UI.
*/
const PAGE_CONTEXT_PROMPTS = {
dashboard: 'You are helping with the DSS dashboard. The user can see project overview, recent activity, and quick actions.',
projects: 'You are helping manage design system projects. The user can create, configure, and monitor projects.',
services: 'You are helping analyze design system services and integrations.',
'quick-wins': 'You are helping find quick win opportunities for design system adoption.',
chat: 'You are a helpful AI assistant for the Design System Server.',
tokens: 'You are helping manage design tokens. The user can extract, validate, sync, and transform tokens between formats.',
components: 'You are helping with React components. The user can generate, audit, and validate design system components.',
figma: 'You are helping with Figma integration. The user can connect files, sync tokens, and view visual diffs.',
docs: 'You are helping with DSS documentation and guides.',
teams: 'You are helping manage team access and permissions for design system projects.',
audit: 'You are helping with design system audits. The user can identify issues, track adoption, and create quick wins.',
plugins: 'You are helping manage DSS plugins and extensions.',
settings: 'You are helping configure DSS settings and preferences.',
storybook: 'You are helping with Storybook setup. The user can generate stories and configure theme documentation.',
};
class ContextStore extends EventTarget {
constructor() {
super();
this.state = {
// MVP1 Core Context (no defaults for project - force selection)
projectId: localStorage.getItem('current_project_id') || null,
teamId: localStorage.getItem('current_team_id') || 'ui',
userId: localStorage.getItem('current_user_id') || 'admin',
capabilities: JSON.parse(localStorage.getItem('user_capabilities') || '[]'),
// MVP2: Admin settings context
adminSettings: {
hostname: localStorage.getItem('admin_hostname') || 'localhost',
port: parseInt(localStorage.getItem('admin_port')) || 6006,
isRemote: localStorage.getItem('admin_isRemote') === 'true' || false,
dssSetupType: localStorage.getItem('admin_setupType') || 'local',
},
// MVP2: Current project configuration
currentProject: null, // Will be set from ProjectStore
currentProjectSkin: localStorage.getItem('current_project_skin') || 'default',
// Legacy fields (keep for backward compatibility)
page: null,
project: null,
team: 'all',
selectedItems: [], // For batch actions on lists
pageData: null, // Current page-specific data (tokens, components, etc.)
};
}
/**
* Updates one or more context values and dispatches an event.
* @param {Partial<typeof this.state>} newContext - An object with keys to update.
*/
setContext(newContext) {
let hasChanged = false;
const changes = {};
for (const key in newContext) {
if (Object.prototype.hasOwnProperty.call(this.state, key)) {
const oldValue = this.state[key];
const newValue = newContext[key];
// Deep comparison for arrays/objects
const isDifferent = Array.isArray(newValue) || typeof newValue === 'object'
? JSON.stringify(oldValue) !== JSON.stringify(newValue)
: oldValue !== newValue;
if (isDifferent) {
this.state[key] = newValue;
changes[key] = { oldValue, newValue };
hasChanged = true;
}
}
}
if (hasChanged) {
this.dispatchEvent(new CustomEvent('context-change', {
detail: {
state: { ...this.state },
changes
}
}));
}
}
/**
* Sets a single context value
* @param {string} key - The context key
* @param {any} value - The new value
*/
set(key, value) {
this.setContext({ [key]: value });
}
/**
* Gets a single context value
* @param {string} key - The context key
* @returns {any}
*/
get(key) {
return this.state[key];
}
/**
* Returns the current state (shallow copy).
* @returns {typeof this.state}
*/
getState() {
return { ...this.state };
}
/**
* Gets a context-aware prompt for the current page.
* Used by the AI chat to provide page-specific assistance.
* @returns {string} A contextual prompt string.
*/
getContextPrompt() {
const page = this.get('page') || 'dashboard';
return PAGE_CONTEXT_PROMPTS[page] || PAGE_CONTEXT_PROMPTS.chat;
}
/**
* A convenience method to subscribe to context changes.
* @param {Function} callback - The function to call on change.
* @returns {Function} An unsubscribe function.
*/
subscribe(callback) {
const handler = (event) => callback(event.detail);
this.addEventListener('context-change', handler);
// Return an unsubscribe function
return () => this.removeEventListener('context-change', handler);
}
/**
* Subscribe to changes on a specific key only
* @param {string} key - The key to watch
* @param {Function} callback - Called with (newValue, oldValue) when key changes
* @returns {Function} Unsubscribe function
*/
subscribeToKey(key, callback) {
const handler = (event) => {
const { changes } = event.detail;
if (changes[key]) {
callback(changes[key].newValue, changes[key].oldValue);
}
};
this.addEventListener('context-change', handler);
return () => this.removeEventListener('context-change', handler);
}
/**
* Clears selection state
*/
clearSelection() {
this.setContext({ selectedItems: [] });
}
/**
* Adds items to selection
* @param {Array} items - Items to add
*/
addToSelection(items) {
const current = this.state.selectedItems || [];
const newItems = Array.isArray(items) ? items : [items];
this.setContext({
selectedItems: [...new Set([...current, ...newItems])]
});
}
/**
* Removes items from selection
* @param {Array} items - Items to remove
*/
removeFromSelection(items) {
const current = this.state.selectedItems || [];
const toRemove = new Set(Array.isArray(items) ? items : [items]);
this.setContext({
selectedItems: current.filter(item => !toRemove.has(item))
});
}
/**
* MVP1: Set active project with storage persistence
* @param {string} id - Project ID
*/
setProject(id) {
if (!id) {
console.error('[ContextStore] Cannot set null projectId');
return;
}
localStorage.setItem('current_project_id', id);
this.setContext({ projectId: id, project: id }); // Update both for compatibility
}
/**
* MVP1: Set active team with storage persistence
* @param {string} id - Team ID (ui, ux, qa, admin)
*/
setTeam(id) {
localStorage.setItem('current_team_id', id);
this.setContext({ teamId: id, team: id }); // Update both for compatibility
}
/**
* MVP1: Check if project is selected
* @returns {boolean}
*/
hasProject() {
return !!this.state.projectId;
}
/**
* MVP1: Get full context for MCP tool calls
* @returns {Object} Context object with projectId, teamId, userId
*/
getMCPContext() {
return {
project_id: this.state.projectId,
team_id: this.state.teamId,
user_id: this.state.userId,
capabilities: this.state.capabilities,
};
}
/**
* MVP2: Update admin settings in context
* @param {Object} settings - Admin settings object
*/
updateAdminSettings(settings) {
this.setContext({
adminSettings: {
...this.state.adminSettings,
...settings
}
});
}
/**
* MVP2: Get current admin settings
* @returns {Object} Admin settings
*/
getAdminSettings() {
return { ...this.state.adminSettings };
}
/**
* MVP2: Update current project information
* @param {Object} project - Project object from ProjectStore
*/
setCurrentProject(project) {
this.setContext({
currentProject: project,
projectId: project?.id || null,
currentProjectSkin: project?.skinSelected || 'default'
});
}
/**
* MVP2: Get current project from context
* @returns {Object|null} Current project or null
*/
getCurrentProject() {
return this.state.currentProject || null;
}
/**
* MVP2: Update current project skin
* @param {string} skin - Skin name
*/
setCurrentProjectSkin(skin) {
localStorage.setItem('current_project_skin', skin);
this.setContext({
currentProjectSkin: skin
});
}
/**
* MVP2: Check if system is configured for remote/headless mode
* @returns {boolean}
*/
isRemoteSetup() {
return this.state.adminSettings.isRemote;
}
/**
* MVP2: Check if system is configured for local mode
* @returns {boolean}
*/
isLocalSetup() {
return !this.state.adminSettings.isRemote;
}
/**
* MVP2: Get DSS setup type
* @returns {string} 'local' or 'remote'
*/
getDSSSetupType() {
return this.state.adminSettings.dssSetupType;
}
}
// Export a singleton instance
const contextStore = new ContextStore();
export default contextStore;

View File

@@ -0,0 +1,250 @@
/**
* project-store.js
* Project configuration store
* Manages project list, current project selection, and project metadata
* MVP3: Integrates with API backend while maintaining localStorage fallback
*/
import apiClient from '../services/api-client.js';
export class ProjectStore {
constructor() {
// Load projects from localStorage (fallback for MVP2)
const stored = localStorage.getItem('projects_list');
this.projects = stored ? JSON.parse(stored) : this.getDefaultProjects();
// Load current project selection
this.currentProjectId = localStorage.getItem('current_project_id') || (this.projects.length > 0 ? this.projects[0].id : null);
this.listeners = new Set();
this.isLoading = false;
this.hasLoadedFromAPI = false;
// Try to load from API on initialization
this.syncWithAPI();
}
async syncWithAPI() {
try {
const projects = await apiClient.getProjects();
this.projects = projects;
this.hasLoadedFromAPI = true;
this.persist();
this.notifyListeners();
} catch (error) {
console.warn('[ProjectStore] Failed to sync with API, using localStorage:', error);
// Continue using localStorage data
}
}
getDefaultProjects() {
return [
{
id: 'DEFAULT-DESIGN-SYSTEM',
name: 'Default Design System',
skinSelected: 'default',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
];
}
/**
* Get all projects
* @returns {Array} Projects array
*/
getProjects() {
return [...this.projects];
}
/**
* Get current project
* @returns {Object|null} Current project or null
*/
getCurrentProject() {
return this.projects.find(p => p.id === this.currentProjectId) || null;
}
/**
* Get project by ID
* @param {string} id - Project ID
* @returns {Object|null} Project or null
*/
getProject(id) {
return this.projects.find(p => p.id === id) || null;
}
/**
* Create new project
* @param {Object} projectData - {name, key, description, figmaFileKey, jiraProjectKey, storybookUrl}
* @returns {Object} Created project
*/
async createProject(projectData) {
try {
// Try API first if authenticated
if (apiClient.accessToken) {
const project = await apiClient.createProject(projectData);
this.projects.push(project);
this.persist();
this.notifyListeners();
return project;
}
} catch (error) {
console.warn('[ProjectStore] API creation failed, using localStorage:', error);
}
// Fallback to localStorage
const now = new Date().toISOString();
const project = {
id: projectData.id || `PROJECT-${Date.now()}`,
name: projectData.name || 'Untitled Project',
key: projectData.key || `KEY-${Date.now()}`,
description: projectData.description || '',
createdAt: now,
updatedAt: now,
...projectData
};
this.projects.push(project);
this.persist();
this.notifyListeners();
return project;
}
/**
* Update project
* @param {string} id - Project ID
* @param {Object} updates - Partial project updates
* @returns {Object|null} Updated project or null
*/
async updateProject(id, updates) {
const project = this.projects.find(p => p.id === id);
if (!project) return null;
try {
// Try API first if authenticated
if (apiClient.accessToken) {
const updated = await apiClient.updateProject(id, updates);
const index = this.projects.findIndex(p => p.id === id);
if (index !== -1) {
this.projects[index] = updated;
}
this.persist();
this.notifyListeners();
return updated;
}
} catch (error) {
console.warn('[ProjectStore] API update failed, using localStorage:', error);
}
// Fallback to localStorage
Object.assign(project, updates, {
updatedAt: new Date().toISOString()
});
this.persist();
this.notifyListeners();
return project;
}
/**
* Delete project
* @param {string} id - Project ID
* @returns {boolean} Success
*/
async deleteProject(id) {
try {
// Try API first if authenticated
if (apiClient.accessToken) {
await apiClient.deleteProject(id);
}
} catch (error) {
console.warn('[ProjectStore] API deletion failed, using localStorage:', error);
}
const index = this.projects.findIndex(p => p.id === id);
if (index === -1) return false;
this.projects.splice(index, 1);
// If deleted project was selected, select first available
if (this.currentProjectId === id) {
this.currentProjectId = this.projects.length > 0 ? this.projects[0].id : null;
localStorage.setItem('current_project_id', this.currentProjectId || '');
}
this.persist();
this.notifyListeners();
return true;
}
/**
* Select/switch to different project
* @param {string} id - Project ID
* @returns {Object|null} Selected project or null
*/
selectProject(id) {
const project = this.projects.find(p => p.id === id);
if (!project) return null;
this.currentProjectId = id;
localStorage.setItem('current_project_id', id);
this.notifyListeners();
return project;
}
/**
* Update selected skin for current project
* @param {string} skin - Skin name
* @returns {Object|null} Updated project
*/
setSkinForCurrentProject(skin) {
if (!this.currentProjectId) return null;
return this.updateProject(this.currentProjectId, { skinSelected: skin });
}
/**
* Persist to localStorage
*/
persist() {
localStorage.setItem('projects_list', JSON.stringify(this.projects));
}
/**
* Subscribe to changes
* @param {Function} callback - Called on state changes
* @returns {Function} Unsubscribe function
*/
subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
notifyListeners() {
this.listeners.forEach(listener => listener({
projects: this.getProjects(),
currentProject: this.getCurrentProject()
}));
}
/**
* Reset to default project
*/
reset() {
localStorage.removeItem('projects_list');
localStorage.removeItem('current_project_id');
this.projects = this.getDefaultProjects();
this.currentProjectId = this.projects[0].id;
this.notifyListeners();
}
}
// Singleton instance
let projectStoreInstance = null;
export function useProjectStore() {
if (!projectStoreInstance) {
projectStoreInstance = new ProjectStore();
}
return projectStoreInstance;
}

View File

@@ -0,0 +1,413 @@
/**
* user-store.js
* User state management store
* Manages current logged-in user, preferences, and integrations
* MVP3: Integrates with backend API while maintaining localStorage persistence
*/
import apiClient from '../services/api-client.js';
export class UserStore {
constructor() {
// Current user state
this.currentUser = this.loadUserFromStorage();
this.isAuthenticated = !!this.currentUser;
this.isLoading = false;
// User preferences
this.preferences = this.loadPreferencesFromStorage() || this.getDefaultPreferences();
// User integrations (API keys, tokens)
this.integrations = this.loadIntegrationsFromStorage() || {};
// Event listeners
this.listeners = new Set();
// Try to verify current user with API on initialization
if (this.isAuthenticated) {
this.verifyUserWithAPI();
}
}
/**
* Get default user preferences
* @returns {Object} Default preferences
*/
getDefaultPreferences() {
return {
theme: 'dark',
language: 'en',
lastTeam: 'ui',
chatCollapsedState: true,
notifications: {
enabled: true,
email: true,
desktop: true
},
layout: {
showChat: true,
showPanel: true,
sidebarWidth: 250
},
editor: {
fontSize: 13,
fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
lineHeight: 1.6
}
};
}
/**
* Load user from localStorage
* @returns {Object|null} User object or null
*/
loadUserFromStorage() {
try {
const stored = localStorage.getItem('current_user');
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.warn('[UserStore] Failed to parse stored user:', error);
return null;
}
}
/**
* Load preferences from localStorage
* @returns {Object|null} Preferences or null
*/
loadPreferencesFromStorage() {
try {
const stored = localStorage.getItem('user_preferences');
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.warn('[UserStore] Failed to parse stored preferences:', error);
return null;
}
}
/**
* Load integrations from localStorage
* @returns {Object} Integrations object
*/
loadIntegrationsFromStorage() {
try {
const stored = localStorage.getItem('user_integrations');
return stored ? JSON.parse(stored) : {};
} catch (error) {
console.warn('[UserStore] Failed to parse stored integrations:', error);
return {};
}
}
/**
* Persist user to localStorage
*/
persistUser() {
if (this.currentUser) {
localStorage.setItem('current_user', JSON.stringify(this.currentUser));
} else {
localStorage.removeItem('current_user');
}
}
/**
* Persist preferences to localStorage
*/
persistPreferences() {
localStorage.setItem('user_preferences', JSON.stringify(this.preferences));
}
/**
* Persist integrations to localStorage
*/
persistIntegrations() {
localStorage.setItem('user_integrations', JSON.stringify(this.integrations));
}
/**
* Verify current user with API
*/
async verifyUserWithAPI() {
try {
const user = await apiClient.getMe();
this.currentUser = user;
this.isAuthenticated = true;
this.persistUser();
this.notifyListeners();
} catch (error) {
console.warn('[UserStore] Failed to verify user with API:', error);
// Fall back to localStorage user
}
}
/**
* Register new user
* @param {string} email - User email
* @param {string} password - User password
* @param {string} name - User name
* @returns {Object} Created user
*/
async register(email, password, name) {
this.isLoading = true;
this.notifyListeners();
try {
const user = await apiClient.register(email, password, name);
this.currentUser = user;
this.isAuthenticated = true;
this.persistUser();
this.notifyListeners();
return user;
} catch (error) {
this.isLoading = false;
this.notifyListeners();
throw error;
}
}
/**
* Login user
* @param {string} email - User email
* @param {string} password - User password
* @returns {Object} Logged-in user
*/
async login(email, password) {
this.isLoading = true;
this.notifyListeners();
try {
const user = await apiClient.login(email, password);
this.currentUser = user;
this.isAuthenticated = true;
this.persistUser();
this.isLoading = false;
this.notifyListeners();
return user;
} catch (error) {
this.isLoading = false;
this.isAuthenticated = false;
this.notifyListeners();
throw error;
}
}
/**
* Logout user
*/
async logout() {
try {
await apiClient.logout();
} catch (error) {
console.warn('[UserStore] API logout failed:', error);
}
this.currentUser = null;
this.isAuthenticated = false;
this.integrations = {};
localStorage.removeItem('current_user');
localStorage.removeItem('user_preferences');
localStorage.removeItem('user_integrations');
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
this.notifyListeners();
}
/**
* Get current user
* @returns {Object|null} Current user or null
*/
getCurrentUser() {
return this.currentUser;
}
/**
* Check if user is authenticated
* @returns {boolean} Authentication status
*/
isLoggedIn() {
return this.isAuthenticated;
}
/**
* Update user profile
* @param {Object} updates - {name, email, avatar, bio}
* @returns {Object} Updated user
*/
async updateProfile(updates) {
try {
// Try API first if authenticated
if (this.isAuthenticated && apiClient.accessToken) {
const updated = await apiClient.updateUser(this.currentUser.id, updates);
this.currentUser = { ...this.currentUser, ...updated };
this.persistUser();
this.notifyListeners();
return updated;
}
} catch (error) {
console.warn('[UserStore] API profile update failed:', error);
}
// Fallback to local update
this.currentUser = { ...this.currentUser, ...updates };
this.persistUser();
this.notifyListeners();
return this.currentUser;
}
/**
* Update user preferences
* @param {Object} updates - Partial preference updates
* @returns {Object} Updated preferences
*/
updatePreferences(updates) {
this.preferences = { ...this.preferences, ...updates };
// Deep merge nested objects
if (updates.notifications) {
this.preferences.notifications = { ...this.preferences.notifications, ...updates.notifications };
}
if (updates.layout) {
this.preferences.layout = { ...this.preferences.layout, ...updates.layout };
}
if (updates.editor) {
this.preferences.editor = { ...this.preferences.editor, ...updates.editor };
}
this.persistPreferences();
this.notifyListeners();
return this.preferences;
}
/**
* Get user preferences
* @returns {Object} Current preferences
*/
getPreferences() {
return { ...this.preferences };
}
/**
* Add or update integration
* @param {string} service - Service name (figma, jira, github, slack, storybook)
* @param {string} apiKey - API key or token for the service
* @param {Object} metadata - Additional metadata (e.g., {projectKey: 'ABC'})
* @returns {Object} Updated integrations
*/
setIntegration(service, apiKey, metadata = {}) {
this.integrations[service] = {
enabled: !!apiKey,
apiKey,
...metadata,
lastUpdated: new Date().toISOString()
};
this.persistIntegrations();
this.notifyListeners();
return this.integrations;
}
/**
* Get specific integration
* @param {string} service - Service name
* @returns {Object|null} Integration config or null
*/
getIntegration(service) {
return this.integrations[service] || null;
}
/**
* Get all integrations
* @returns {Object} All integrations
*/
getIntegrations() {
return { ...this.integrations };
}
/**
* Remove integration
* @param {string} service - Service name
* @returns {Object} Updated integrations
*/
removeIntegration(service) {
delete this.integrations[service];
this.persistIntegrations();
this.notifyListeners();
return this.integrations;
}
/**
* Get user avatar (with fallback)
* @returns {string} Avatar URL or initials
*/
getAvatar() {
if (!this.currentUser) return null;
if (this.currentUser.avatar) return this.currentUser.avatar;
// Generate initials-based avatar
const initials = (this.currentUser.name || this.currentUser.email)
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase();
return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%23007acc' width='32' height='32'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='white' font-size='14' font-weight='bold' font-family='sans-serif' transform='translate(0, 1)'%3E${initials}%3C/text%3E%3C/svg%3E`;
}
/**
* Get user display name
* @returns {string} Name or email
*/
getDisplayName() {
if (!this.currentUser) return 'Anonymous';
return this.currentUser.name || this.currentUser.email;
}
/**
* Subscribe to user state changes
* @param {Function} callback - Called with {currentUser, preferences, integrations, isAuthenticated}
* @returns {Function} Unsubscribe function
*/
subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
/**
* Notify all listeners of state changes
*/
notifyListeners() {
this.listeners.forEach(listener => listener({
currentUser: this.currentUser,
preferences: this.getPreferences(),
integrations: this.getIntegrations(),
isAuthenticated: this.isAuthenticated
}));
}
/**
* Reset to default state
*/
reset() {
this.currentUser = null;
this.isAuthenticated = false;
this.preferences = this.getDefaultPreferences();
this.integrations = {};
localStorage.removeItem('current_user');
localStorage.removeItem('user_preferences');
localStorage.removeItem('user_integrations');
this.notifyListeners();
}
}
// Singleton instance
let userStoreInstance = null;
export function useUserStore() {
if (!userStoreInstance) {
userStoreInstance = new UserStore();
}
return userStoreInstance;
}