/** * 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;