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:
113
admin-ui/js/stores/admin-store.js
Normal file
113
admin-ui/js/stores/admin-store.js
Normal 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;
|
||||
}
|
||||
548
admin-ui/js/stores/app-store.js
Normal file
548
admin-ui/js/stores/app-store.js
Normal 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;
|
||||
318
admin-ui/js/stores/context-store.js
Normal file
318
admin-ui/js/stores/context-store.js
Normal 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;
|
||||
250
admin-ui/js/stores/project-store.js
Normal file
250
admin-ui/js/stores/project-store.js
Normal 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;
|
||||
}
|
||||
413
admin-ui/js/stores/user-store.js
Normal file
413
admin-ui/js/stores/user-store.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user