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
549 lines
13 KiB
JavaScript
549 lines
13 KiB
JavaScript
/**
|
|
* 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;
|