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:
334
admin-ui/js/utils/component-helpers.js
Normal file
334
admin-ui/js/utils/component-helpers.js
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* component-helpers.js
|
||||
* Shared utilities for panel components
|
||||
* Provides standardized loading, error, and empty states
|
||||
*/
|
||||
|
||||
export class ComponentHelpers {
|
||||
/**
|
||||
* Render a loading spinner with optional message
|
||||
* @param {string} message - Optional loading message
|
||||
* @returns {string} HTML string for loading state
|
||||
*/
|
||||
static renderLoading(message = 'Loading...') {
|
||||
return `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
color: var(--vscode-text-dim);
|
||||
">
|
||||
<div style="
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--vscode-border);
|
||||
border-top-color: var(--vscode-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
"></div>
|
||||
<div style="margin-top: 16px; font-size: 12px;">${message}</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an error state with message
|
||||
* @param {string} message - Error message to display
|
||||
* @param {Error} error - Optional error object for details
|
||||
* @returns {string} HTML string for error state
|
||||
*/
|
||||
static renderError(message, error = null) {
|
||||
const details = error ? `<div style="
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background-color: rgba(244, 135, 113, 0.1);
|
||||
border-radius: 2px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
color: #f48771;
|
||||
">${error.message || error.toString()}</div>` : '';
|
||||
|
||||
return `
|
||||
<div style="
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #f48771;
|
||||
">
|
||||
<div style="font-size: 32px; margin-bottom: 12px;">⚠️</div>
|
||||
<div style="font-size: 13px; font-weight: 500;">${message}</div>
|
||||
${details}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an empty state with message
|
||||
* @param {string} message - Empty state message
|
||||
* @param {string} icon - Optional icon/emoji
|
||||
* @returns {string} HTML string for empty state
|
||||
*/
|
||||
static renderEmpty(message = 'No data available', icon = '📭') {
|
||||
return `
|
||||
<div style="
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--vscode-text-dim);
|
||||
">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">${icon}</div>
|
||||
<div style="font-size: 13px;">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for display
|
||||
* @param {Date|string|number} date - Date to format
|
||||
* @param {boolean} includeTime - Include time in output
|
||||
* @returns {string} Formatted date string
|
||||
*/
|
||||
static formatTimestamp(date, includeTime = true) {
|
||||
if (!date) return 'N/A';
|
||||
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
|
||||
if (isNaN(d.getTime())) return 'Invalid Date';
|
||||
|
||||
const dateStr = d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
if (!includeTime) return dateStr;
|
||||
|
||||
const timeStr = d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 minutes ago")
|
||||
* @param {Date|string|number} date - Date to format
|
||||
* @returns {string} Relative time string
|
||||
*/
|
||||
static formatRelativeTime(date) {
|
||||
if (!date) return 'N/A';
|
||||
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) return 'Invalid Date';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - d;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
|
||||
|
||||
return this.formatTimestamp(d, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to max length with ellipsis
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLength - Maximum length
|
||||
* @returns {string} Truncated text
|
||||
*/
|
||||
static truncateText(text, maxLength = 100) {
|
||||
if (!text || text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped HTML
|
||||
*/
|
||||
static escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format
|
||||
* @param {number} bytes - Size in bytes
|
||||
* @returns {string} Formatted size string
|
||||
*/
|
||||
static formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (!bytes) return 'N/A';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in human-readable format
|
||||
* @param {number} ms - Duration in milliseconds
|
||||
* @returns {string} Formatted duration string
|
||||
*/
|
||||
static formatDuration(ms) {
|
||||
if (ms === 0) return '0ms';
|
||||
if (!ms) return 'N/A';
|
||||
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
|
||||
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
const minutes = Math.floor((ms % 3600000) / 60000);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a badge element
|
||||
* @param {string} label - Badge label
|
||||
* @param {string} type - Badge type (success, warning, error, info)
|
||||
* @returns {string} HTML string for badge
|
||||
*/
|
||||
static createBadge(label, type = 'info') {
|
||||
const colors = {
|
||||
success: '#89d185',
|
||||
warning: '#dbb765',
|
||||
error: '#f48771',
|
||||
info: '#75beff'
|
||||
};
|
||||
|
||||
const color = colors[type] || colors.info;
|
||||
|
||||
return `
|
||||
<span style="
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background-color: rgba(${this.hexToRgb(color)}, 0.2);
|
||||
color: ${color};
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
">${label}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to RGB values
|
||||
* @param {string} hex - Hex color
|
||||
* @returns {string} RGB values (e.g., "255, 0, 0")
|
||||
*/
|
||||
static hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
|
||||
: '128, 128, 128';
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function execution
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Wait time in milliseconds
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
static debounce(func, wait = 300) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON safely with fallback
|
||||
* @param {string} jsonString - JSON string to parse
|
||||
* @param {*} fallback - Fallback value if parse fails
|
||||
* @returns {*} Parsed object or fallback
|
||||
*/
|
||||
static safeJsonParse(jsonString, fallback = null) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse JSON:', e);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a table from array of objects
|
||||
* @param {Array<Object>} data - Array of objects
|
||||
* @param {Array<string>} columns - Column names to display
|
||||
* @returns {string} HTML string for table
|
||||
*/
|
||||
static createTable(data, columns = null) {
|
||||
if (!data || data.length === 0) {
|
||||
return this.renderEmpty('No data to display', '📋');
|
||||
}
|
||||
|
||||
const cols = columns || Object.keys(data[0]);
|
||||
|
||||
const headerRow = cols.map(col =>
|
||||
`<th style="
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--vscode-border);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-text-dim);
|
||||
">${col}</th>`
|
||||
).join('');
|
||||
|
||||
const dataRows = data.map(row => `
|
||||
<tr style="border-bottom: 1px solid var(--vscode-border);">
|
||||
${cols.map(col => `
|
||||
<td style="
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-text);
|
||||
">${this.escapeHtml(String(row[col] ?? ''))}</td>
|
||||
`).join('')}
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: var(--vscode-sidebar);
|
||||
">
|
||||
<thead>
|
||||
<tr>${headerRow}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${dataRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
163
admin-ui/js/utils/console-forwarder.js
Normal file
163
admin-ui/js/utils/console-forwarder.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file console-forwarder.js
|
||||
* @description Intercepts browser console logs and forwards them to the server.
|
||||
* Handles circular references, batches requests, and prevents infinite loops.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
// CONFIGURATION
|
||||
const LOG_ENDPOINT = '/api/logs/browser';
|
||||
const BATCH_SIZE = 50;
|
||||
const FLUSH_INTERVAL_MS = 2000;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
// STATE
|
||||
let logQueue = [];
|
||||
let flushTimer = null;
|
||||
let isSending = false;
|
||||
let retryCount = 0;
|
||||
|
||||
// CIRCULAR REFERENCE SAFE SERIALIZER
|
||||
const safeStringify = (obj) => {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(obj, (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
// Handle Error objects explicitly as they don't stringify well by default
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
name: value.name
|
||||
};
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
// LOG QUEUE MANAGER
|
||||
const flushQueue = async () => {
|
||||
if (logQueue.length === 0 || isSending) return;
|
||||
|
||||
const batch = [...logQueue];
|
||||
logQueue = []; // Clear queue immediately to prevent duplicates
|
||||
isSending = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(LOG_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ logs: batch }),
|
||||
// critical: prevent this fetch from triggering global error handlers
|
||||
// if it fails, to avoid infinite loops
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server responded with ${response.status}`);
|
||||
}
|
||||
retryCount = 0;
|
||||
} catch (err) {
|
||||
// DO NOT log errors here to prevent infinite loops
|
||||
// If network fails, put specific logs back or drop them to prevent memory leaks
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
// Re-queue logs at the front
|
||||
logQueue = [...batch, ...logQueue];
|
||||
}
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimer) return;
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushQueue();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const pushLog = (level, args) => {
|
||||
// timestamp
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// safe serialization of arguments
|
||||
const serializedArgs = args.map(arg => {
|
||||
try {
|
||||
if (typeof arg === 'string') return arg;
|
||||
return JSON.parse(safeStringify(arg));
|
||||
} catch (e) {
|
||||
return '[Unserializable]';
|
||||
}
|
||||
});
|
||||
|
||||
logQueue.push({
|
||||
level,
|
||||
timestamp,
|
||||
message: serializedArgs.join(' '), // Flatten for simple viewing
|
||||
data: serializedArgs // Keep structured for deep inspection if backend supports it
|
||||
});
|
||||
|
||||
if (logQueue.length >= BATCH_SIZE) {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
flushQueue();
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
};
|
||||
|
||||
// INTERCEPTORS
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
};
|
||||
|
||||
const methods = ['log', 'info', 'warn', 'error', 'debug'];
|
||||
|
||||
methods.forEach((method) => {
|
||||
console[method] = (...args) => {
|
||||
// 1. Call original method so developer tools still work
|
||||
originalConsole[method].apply(console, args);
|
||||
|
||||
// 2. Push to queue
|
||||
try {
|
||||
pushLog(method, args);
|
||||
} catch (e) {
|
||||
// Silent fail - do not use console.error here!
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// GLOBAL ERROR HANDLERS
|
||||
window.addEventListener('error', (event) => {
|
||||
pushLog('error', [`Uncaught Exception: ${event.message}`, event.filename, event.lineno, event.colno, event.error]);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
pushLog('error', ['Unhandled Promise Rejection:', event.reason]);
|
||||
});
|
||||
|
||||
// PAGE UNLOAD FLUSH
|
||||
// Use sendBeacon for reliable "last gasp" logging
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden' && logQueue.length > 0) {
|
||||
const batch = safeStringify({ logs: logQueue });
|
||||
// sendBeacon is more reliable on unload than fetch
|
||||
if (navigator.sendBeacon) {
|
||||
const blob = new Blob([batch], { type: 'application/json' });
|
||||
navigator.sendBeacon(LOG_ENDPOINT, blob);
|
||||
logQueue = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.info('[Console Forwarder] Initialized. Monitoring active.');
|
||||
})();
|
||||
118
admin-ui/js/utils/incognito-detector.js
Normal file
118
admin-ui/js/utils/incognito-detector.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* incognito-detector.js
|
||||
* Detects if the browser is running in incognito/private mode
|
||||
* Uses LAZY initialization to avoid cascading failures at module load time
|
||||
*/
|
||||
|
||||
let isIncognitoResult = null;
|
||||
let storageTypeResult = null;
|
||||
|
||||
/**
|
||||
* Detect if browser is in incognito/private mode
|
||||
* Works by trying to write to localStorage and catching the silent failure
|
||||
* Result is memoized after first call
|
||||
*/
|
||||
function detectIncognito() {
|
||||
try {
|
||||
const testKey = '__incognito_test__';
|
||||
const testValue = Date.now().toString();
|
||||
|
||||
// Try to write to localStorage
|
||||
localStorage.setItem(testKey, testValue);
|
||||
|
||||
// Try to read it back
|
||||
const retrieved = localStorage.getItem(testKey);
|
||||
|
||||
// Clean up
|
||||
localStorage.removeItem(testKey);
|
||||
|
||||
// If we can write and read, we're not in incognito
|
||||
isIncognitoResult = retrieved !== testValue;
|
||||
storageTypeResult = isIncognitoResult ? 'sessionStorage' : 'localStorage';
|
||||
|
||||
if (isIncognitoResult) {
|
||||
console.log('[IncognitoDetector] Incognito mode detected - using sessionStorage');
|
||||
}
|
||||
return isIncognitoResult;
|
||||
|
||||
} catch (e) {
|
||||
// If localStorage access throws an error, we're definitely in incognito
|
||||
console.warn('[IncognitoDetector] Storage access failed, assuming incognito mode:', e);
|
||||
isIncognitoResult = true;
|
||||
storageTypeResult = 'sessionStorage';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate storage object for this browser context
|
||||
* Triggers incognito detection if not yet done
|
||||
*/
|
||||
export function getStorage() {
|
||||
if (isIncognitoResult === null) {
|
||||
detectIncognito();
|
||||
}
|
||||
return isIncognitoResult ? sessionStorage : localStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're in incognito mode
|
||||
* Triggers detection if not yet done
|
||||
*/
|
||||
export function checkIncognito() {
|
||||
if (isIncognitoResult === null) {
|
||||
detectIncognito();
|
||||
}
|
||||
return isIncognitoResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an item in the appropriate storage
|
||||
*/
|
||||
export function setItem(key, value) {
|
||||
try {
|
||||
getStorage().setItem(key, value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[IncognitoDetector] Failed to store item:', key, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item from the appropriate storage
|
||||
*/
|
||||
export function getItem(key) {
|
||||
try {
|
||||
return getStorage().getItem(key);
|
||||
} catch (e) {
|
||||
console.error('[IncognitoDetector] Failed to retrieve item:', key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the appropriate storage
|
||||
*/
|
||||
export function removeItem(key) {
|
||||
try {
|
||||
getStorage().removeItem(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[IncognitoDetector] Failed to remove item:', key, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items from the appropriate storage
|
||||
*/
|
||||
export function clear() {
|
||||
try {
|
||||
getStorage().clear();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[IncognitoDetector] Failed to clear storage:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
134
admin-ui/js/utils/logger.js
Normal file
134
admin-ui/js/utils/logger.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* logger.js
|
||||
* Centralized logging utility for DSS
|
||||
* Replaces direct console.log usage for structured, environment-aware logging
|
||||
*/
|
||||
|
||||
// Determine if we're in development mode
|
||||
const isDevelopment = () => {
|
||||
// Check various indicators of development environment
|
||||
return (
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
window.location.port === '3456' || // DSS development port
|
||||
window.location.search.includes('debug=true')
|
||||
);
|
||||
};
|
||||
|
||||
// Check if debug mode is enabled
|
||||
const isDebugEnabled = () => {
|
||||
return isDevelopment() || localStorage.getItem('dss_debug') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Format log message with timestamp and component context
|
||||
* @param {string} level - Log level (DEBUG, INFO, WARN, ERROR)
|
||||
* @param {string} message - Log message
|
||||
* @param {...any} args - Additional arguments
|
||||
* @returns {Array} Formatted arguments for console methods
|
||||
*/
|
||||
function formatLog(level, message, ...args) {
|
||||
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1); // HH:MM:SS.mmm
|
||||
const prefix = `[${timestamp}] [${level}]`;
|
||||
|
||||
return [prefix, message, ...args];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger object with structured logging methods
|
||||
*/
|
||||
export const logger = {
|
||||
/**
|
||||
* Debug logging - only shown in development or when debug flag enabled
|
||||
* Use for detailed diagnostic information
|
||||
* @param {string} message - Log message
|
||||
* @param {...any} args - Additional data to log
|
||||
*/
|
||||
debug(message, ...args) {
|
||||
if (isDebugEnabled()) {
|
||||
console.log(...formatLog('DEBUG', message, ...args));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Info logging - informational messages about normal operations
|
||||
* @param {string} message - Log message
|
||||
* @param {...any} args - Additional data to log
|
||||
*/
|
||||
info(message, ...args) {
|
||||
console.info(...formatLog('INFO', message, ...args));
|
||||
},
|
||||
|
||||
/**
|
||||
* Warning logging - potentially harmful situations
|
||||
* @param {string} message - Log message
|
||||
* @param {...any} args - Additional data to log
|
||||
*/
|
||||
warn(message, ...args) {
|
||||
console.warn(...formatLog('WARN', message, ...args));
|
||||
},
|
||||
|
||||
/**
|
||||
* Error logging - error events that might still allow the application to continue
|
||||
* @param {string} message - Log message
|
||||
* @param {...any} args - Additional data to log (typically Error objects)
|
||||
*/
|
||||
error(message, ...args) {
|
||||
console.error(...formatLog('ERROR', message, ...args));
|
||||
},
|
||||
|
||||
/**
|
||||
* Performance timing helper
|
||||
* Returns a function that logs elapsed time when called
|
||||
* @param {string} label - Label for the timing measurement
|
||||
* @returns {Function} Function to call when operation completes
|
||||
*
|
||||
* @example
|
||||
* const endTimer = logger.time('Data load');
|
||||
* await loadData();
|
||||
* endTimer(); // Logs: [TIME] Data load: 234ms
|
||||
*/
|
||||
time(label) {
|
||||
const start = performance.now();
|
||||
return () => {
|
||||
if (isDebugEnabled()) {
|
||||
const elapsed = (performance.now() - start).toFixed(2);
|
||||
console.log(...formatLog('TIME', `${label}: ${elapsed}ms`));
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable debug mode (persists in localStorage)
|
||||
*/
|
||||
enableDebug() {
|
||||
localStorage.setItem('dss_debug', 'true');
|
||||
this.info('Debug mode enabled');
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable debug mode
|
||||
*/
|
||||
disableDebug() {
|
||||
localStorage.removeItem('dss_debug');
|
||||
this.info('Debug mode disabled');
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isDebugEnabled() {
|
||||
return isDebugEnabled();
|
||||
}
|
||||
};
|
||||
|
||||
// Export as default for convenience
|
||||
export default logger;
|
||||
|
||||
// Make logger available globally for console debugging
|
||||
if (isDevelopment()) {
|
||||
window.dssLogger = logger;
|
||||
logger.debug('[Logger] Logger utility initialized. Access via window.dssLogger');
|
||||
logger.debug('[Logger] Enable debug: logger.enableDebug() | Disable: logger.disableDebug()');
|
||||
}
|
||||
546
admin-ui/js/utils/tool-templates.js
Normal file
546
admin-ui/js/utils/tool-templates.js
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* tool-templates.js
|
||||
* Reusable template functions for building team-specific tool components
|
||||
* Follows DRY principles to avoid code duplication across 14 team tools
|
||||
*/
|
||||
|
||||
import { ComponentHelpers } from './component-helpers.js';
|
||||
import toolBridge from '../services/tool-bridge.js';
|
||||
|
||||
/**
|
||||
* Create a side-by-side comparison view
|
||||
* Used for: Storybook/Figma, Storybook/Live, Figma/Live comparisons
|
||||
*
|
||||
* @param {Object} config
|
||||
* @param {string} config.leftTitle - Title for left panel
|
||||
* @param {string} config.rightTitle - Title for right panel
|
||||
* @param {string} config.leftSrc - URL or content for left panel
|
||||
* @param {string} config.rightSrc - URL or content for right panel
|
||||
* @param {Function} config.onSync - Optional sync scroll callback
|
||||
* @returns {string} HTML template
|
||||
*/
|
||||
export function createComparisonView(config) {
|
||||
const {
|
||||
leftTitle = 'Left',
|
||||
rightTitle = 'Right',
|
||||
leftSrc = '',
|
||||
rightSrc = '',
|
||||
onSync = null
|
||||
} = config;
|
||||
|
||||
return `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Toolbar -->
|
||||
<div style="padding: 12px 16px; border-bottom: 1px solid var(--vscode-border); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<button id="sync-scroll-btn" class="button" style="font-size: 11px; padding: 4px 12px;">
|
||||
🔗 Sync Scroll
|
||||
</button>
|
||||
<button id="reset-zoom-btn" class="button" style="font-size: 11px; padding: 4px 12px;">
|
||||
🔍 Reset Zoom
|
||||
</button>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">
|
||||
Use mouse wheel to zoom, drag to pan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison Panels -->
|
||||
<div style="flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--vscode-border); overflow: hidden;">
|
||||
<!-- Left Panel -->
|
||||
<div style="background: var(--vscode-bg); display: flex; flex-direction: column;">
|
||||
<div style="padding: 8px 12px; background: var(--vscode-sidebar); border-bottom: 1px solid var(--vscode-border); font-size: 12px; font-weight: 600;">
|
||||
${ComponentHelpers.escapeHtml(leftTitle)}
|
||||
</div>
|
||||
<div id="left-panel-content" style="flex: 1; overflow: auto; position: relative;">
|
||||
${leftSrc ? `<iframe src="${ComponentHelpers.escapeHtml(leftSrc)}" style="width: 100%; height: 100%; border: none;"></iframe>` : ComponentHelpers.renderEmpty('Select content to display', '📄')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<div style="background: var(--vscode-bg); display: flex; flex-direction: column;">
|
||||
<div style="padding: 8px 12px; background: var(--vscode-sidebar); border-bottom: 1px solid var(--vscode-border); font-size: 12px; font-weight: 600;">
|
||||
${ComponentHelpers.escapeHtml(rightTitle)}
|
||||
</div>
|
||||
<div id="right-panel-content" style="flex: 1; overflow: auto; position: relative;">
|
||||
${rightSrc ? `<iframe src="${ComponentHelpers.escapeHtml(rightSrc)}" style="width: 100%; height: 100%; border: none;"></iframe>` : ComponentHelpers.renderEmpty('Select content to display', '📄')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list view with search, filter, and actions
|
||||
* Used for: Token list, Asset list, Component list
|
||||
*
|
||||
* @param {Object} config
|
||||
* @param {string} config.title - List title
|
||||
* @param {Array} config.items - Array of items to display
|
||||
* @param {Array} config.columns - Column definitions [{ key, label, render }]
|
||||
* @param {Array} config.actions - Action buttons [{ label, icon, onClick }]
|
||||
* @param {Function} config.onSearch - Search callback
|
||||
* @param {Function} config.onFilter - Filter callback
|
||||
* @returns {string} HTML template
|
||||
*/
|
||||
export function createListView(config) {
|
||||
const {
|
||||
title = 'Items',
|
||||
items = [],
|
||||
columns = [],
|
||||
actions = [],
|
||||
onSearch = null,
|
||||
onFilter = null
|
||||
} = config;
|
||||
|
||||
return `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Header -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h2 style="font-size: 16px; font-weight: 600;">${ComponentHelpers.escapeHtml(title)}</h2>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
${actions.map((action, idx) => `
|
||||
<button class="action-btn button" data-action-idx="${idx}" style="font-size: 11px; padding: 4px 12px;">
|
||||
${action.icon || ''} ${ComponentHelpers.escapeHtml(action.label)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="Search..."
|
||||
class="input"
|
||||
style="flex: 1; font-size: 12px;"
|
||||
/>
|
||||
<select id="filter-select" class="input" style="font-size: 12px; width: 150px;">
|
||||
<option value="">All Types</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div style="flex: 1; overflow: auto;">
|
||||
${items.length === 0 ? ComponentHelpers.renderEmpty(`No ${title.toLowerCase()} found`, '📦') : `
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
|
||||
<thead style="position: sticky; top: 0; background: var(--vscode-sidebar); z-index: 1;">
|
||||
<tr>
|
||||
${columns.map(col => `
|
||||
<th style="padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--vscode-border); font-weight: 600;">
|
||||
${ComponentHelpers.escapeHtml(col.label)}
|
||||
</th>
|
||||
`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items.map((item, itemIdx) => `
|
||||
<tr style="border-bottom: 1px solid var(--vscode-border);" onmouseover="this.style.background='var(--vscode-list-hoverBackground)'" onmouseout="this.style.background='transparent'">
|
||||
${columns.map(col => `
|
||||
<td style="padding: 8px 12px;">
|
||||
${col.render ? col.render(item) : ComponentHelpers.escapeHtml(String(item[col.key] || ''))}
|
||||
</td>
|
||||
`).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="padding: 12px 16px; border-top: 1px solid var(--vscode-border); font-size: 11px; color: var(--vscode-text-dim);">
|
||||
Showing ${items.length} ${title.toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an editor view with save/export functionality
|
||||
* Used for: ESRE editor, configuration editors
|
||||
*
|
||||
* @param {Object} config
|
||||
* @param {string} config.title - Editor title
|
||||
* @param {string} config.content - Initial content
|
||||
* @param {string} config.language - Syntax highlighting language (text, json, yaml, etc.)
|
||||
* @param {Function} config.onSave - Save callback
|
||||
* @param {Function} config.onExport - Export callback
|
||||
* @returns {string} HTML template
|
||||
*/
|
||||
export function createEditorView(config) {
|
||||
const {
|
||||
title = 'Editor',
|
||||
content = '',
|
||||
language = 'text',
|
||||
onSave = null,
|
||||
onExport = null
|
||||
} = config;
|
||||
|
||||
return `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Header -->
|
||||
<div style="padding: 12px 16px; border-bottom: 1px solid var(--vscode-border); display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="font-size: 14px; font-weight: 600;">${ComponentHelpers.escapeHtml(title)}</h2>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="editor-save-btn" class="button" style="font-size: 11px; padding: 4px 12px;">
|
||||
💾 Save
|
||||
</button>
|
||||
<button id="editor-export-btn" class="button" style="font-size: 11px; padding: 4px 12px;">
|
||||
📥 Export
|
||||
</button>
|
||||
<button id="editor-clear-btn" class="button" style="font-size: 11px; padding: 4px 12px;">
|
||||
🗑️ Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div style="flex: 1; overflow: hidden; position: relative;">
|
||||
<textarea
|
||||
id="editor-content"
|
||||
class="input"
|
||||
style="width: 100%; height: 100%; resize: none; font-family: 'Courier New', monospace; font-size: 12px; padding: 16px; border: none;"
|
||||
placeholder="Enter content here..."
|
||||
>${ComponentHelpers.escapeHtml(content)}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<div style="padding: 8px 16px; border-top: 1px solid var(--vscode-border); display: flex; justify-content: space-between; font-size: 10px; color: var(--vscode-text-dim);">
|
||||
<span id="editor-stats">0 lines, 0 characters</span>
|
||||
<span>Language: ${language}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a gallery/grid view for visual content
|
||||
* Used for: Screenshot gallery, navigation demos
|
||||
*
|
||||
* @param {Object} config
|
||||
* @param {string} config.title - Gallery title
|
||||
* @param {Array} config.items - Array of items with { id, src, title, subtitle }
|
||||
* @param {Function} config.onItemClick - Item click callback
|
||||
* @param {Function} config.onDelete - Delete callback
|
||||
* @returns {string} HTML template
|
||||
*/
|
||||
export function createGalleryView(config) {
|
||||
const {
|
||||
title = 'Gallery',
|
||||
items = [],
|
||||
onItemClick = null,
|
||||
onDelete = null
|
||||
} = config;
|
||||
|
||||
return `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Header -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="font-size: 16px; font-weight: 600;">${ComponentHelpers.escapeHtml(title)}</h2>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">
|
||||
${items.length} ${items.length === 1 ? 'item' : 'items'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery Grid -->
|
||||
<div style="flex: 1; overflow: auto; padding: 16px;">
|
||||
${items.length === 0 ? ComponentHelpers.renderEmpty(`No ${title.toLowerCase()} available`, '🖼️') : `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px;">
|
||||
${items.map((item, idx) => `
|
||||
<div class="gallery-item" data-item-idx="${idx}" style="
|
||||
background: var(--vscode-sidebar);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
" onmouseover="this.style.transform='translateY(-4px)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.3)'" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='none'">
|
||||
<!-- Image/Preview -->
|
||||
<div style="aspect-ratio: 16/9; background: var(--vscode-bg); display: flex; align-items: center; justify-content: center; overflow: hidden;">
|
||||
${item.src ? `<img src="${ComponentHelpers.escapeHtml(item.src)}" style="width: 100%; height: 100%; object-fit: cover;" />` : '<div style="font-size: 48px;">📄</div>'}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div style="padding: 12px;">
|
||||
<div style="font-size: 12px; font-weight: 600; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
${ComponentHelpers.escapeHtml(item.title || 'Untitled')}
|
||||
</div>
|
||||
${item.subtitle ? `<div style="font-size: 10px; color: var(--vscode-text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${ComponentHelpers.escapeHtml(item.subtitle)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
${onDelete ? `
|
||||
<div style="padding: 0 12px 12px;">
|
||||
<button class="gallery-delete-btn button" data-item-idx="${idx}" style="width: 100%; font-size: 10px; padding: 4px;">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a form view with validation
|
||||
* Used for: Project analysis configuration, quick wins settings
|
||||
*
|
||||
* @param {Object} config
|
||||
* @param {string} config.title - Form title
|
||||
* @param {Array} config.fields - Field definitions [{ name, label, type, placeholder, required }]
|
||||
* @param {Function} config.onSubmit - Submit callback
|
||||
* @returns {string} HTML template
|
||||
*/
|
||||
export function createFormView(config) {
|
||||
const {
|
||||
title = 'Configuration',
|
||||
fields = [],
|
||||
onSubmit = null
|
||||
} = config;
|
||||
|
||||
return `
|
||||
<div style="padding: 24px; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="font-size: 16px; font-weight: 600; margin-bottom: 24px;">${ComponentHelpers.escapeHtml(title)}</h2>
|
||||
|
||||
<form id="config-form" style="display: flex; flex-direction: column; gap: 16px;">
|
||||
${fields.map(field => `
|
||||
<div>
|
||||
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px;">
|
||||
${ComponentHelpers.escapeHtml(field.label)}
|
||||
${field.required ? '<span style="color: #f48771;">*</span>' : ''}
|
||||
</label>
|
||||
${field.type === 'textarea' ? `
|
||||
<textarea
|
||||
name="${field.name}"
|
||||
class="input"
|
||||
placeholder="${ComponentHelpers.escapeHtml(field.placeholder || '')}"
|
||||
style="width: 100%; min-height: 80px; font-size: 12px;"
|
||||
${field.required ? 'required' : ''}
|
||||
></textarea>
|
||||
` : field.type === 'select' ? `
|
||||
<select
|
||||
name="${field.name}"
|
||||
class="input"
|
||||
style="width: 100%; font-size: 12px;"
|
||||
${field.required ? 'required' : ''}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
${(field.options || []).map(opt => `
|
||||
<option value="${ComponentHelpers.escapeHtml(opt.value)}">${ComponentHelpers.escapeHtml(opt.label)}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
` : `
|
||||
<input
|
||||
type="${field.type || 'text'}"
|
||||
name="${field.name}"
|
||||
class="input"
|
||||
placeholder="${ComponentHelpers.escapeHtml(field.placeholder || '')}"
|
||||
style="width: 100%; font-size: 12px;"
|
||||
${field.required ? 'required' : ''}
|
||||
/>
|
||||
`}
|
||||
${field.description ? `<div style="font-size: 10px; color: var(--vscode-text-dim); margin-top: 4px;">${ComponentHelpers.escapeHtml(field.description)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
|
||||
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 12px;">
|
||||
<button type="button" id="form-cancel-btn" class="button" style="font-size: 12px;">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="button" style="font-size: 12px;">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for comparison view
|
||||
*/
|
||||
export function setupComparisonHandlers(container, config) {
|
||||
const syncBtn = container.querySelector('#sync-scroll-btn');
|
||||
const resetBtn = container.querySelector('#reset-zoom-btn');
|
||||
const leftPanel = container.querySelector('#left-panel-content');
|
||||
const rightPanel = container.querySelector('#right-panel-content');
|
||||
|
||||
let syncEnabled = false;
|
||||
|
||||
if (syncBtn && leftPanel && rightPanel) {
|
||||
syncBtn.addEventListener('click', () => {
|
||||
syncEnabled = !syncEnabled;
|
||||
syncBtn.textContent = syncEnabled ? '🔗 Synced' : '🔗 Sync Scroll';
|
||||
|
||||
if (syncEnabled) {
|
||||
leftPanel.addEventListener('scroll', syncScroll);
|
||||
rightPanel.addEventListener('scroll', syncScroll);
|
||||
} else {
|
||||
leftPanel.removeEventListener('scroll', syncScroll);
|
||||
rightPanel.removeEventListener('scroll', syncScroll);
|
||||
}
|
||||
});
|
||||
|
||||
function syncScroll(e) {
|
||||
if (!syncEnabled) return;
|
||||
const source = e.target;
|
||||
const target = source === leftPanel ? rightPanel : leftPanel;
|
||||
target.scrollTop = source.scrollTop;
|
||||
target.scrollLeft = source.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => {
|
||||
const iframes = container.querySelectorAll('iframe');
|
||||
iframes.forEach(iframe => {
|
||||
iframe.style.transform = 'scale(1)';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for list view
|
||||
*/
|
||||
export function setupListHandlers(container, config) {
|
||||
const searchInput = container.querySelector('#search-input');
|
||||
const filterSelect = container.querySelector('#filter-select');
|
||||
const actionBtns = container.querySelectorAll('.action-btn');
|
||||
|
||||
if (searchInput && config.onSearch) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
config.onSearch(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (filterSelect && config.onFilter) {
|
||||
filterSelect.addEventListener('change', (e) => {
|
||||
config.onFilter(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (actionBtns && config.actions) {
|
||||
actionBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt(btn.dataset.actionIdx);
|
||||
if (config.actions[idx] && config.actions[idx].onClick) {
|
||||
config.actions[idx].onClick();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for editor view
|
||||
*/
|
||||
export function setupEditorHandlers(container, config) {
|
||||
const saveBtn = container.querySelector('#editor-save-btn');
|
||||
const exportBtn = container.querySelector('#editor-export-btn');
|
||||
const clearBtn = container.querySelector('#editor-clear-btn');
|
||||
const textarea = container.querySelector('#editor-content');
|
||||
const stats = container.querySelector('#editor-stats');
|
||||
|
||||
function updateStats() {
|
||||
if (textarea && stats) {
|
||||
const lines = textarea.value.split('\n').length;
|
||||
const chars = textarea.value.length;
|
||||
stats.textContent = `${lines} lines, ${chars} characters`;
|
||||
}
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.addEventListener('input', updateStats);
|
||||
updateStats();
|
||||
}
|
||||
|
||||
if (saveBtn && config.onSave) {
|
||||
saveBtn.addEventListener('click', () => {
|
||||
config.onSave(textarea.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (exportBtn && config.onExport) {
|
||||
exportBtn.addEventListener('click', () => {
|
||||
config.onExport(textarea.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (clearBtn && textarea) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
if (confirm('Clear all content?')) {
|
||||
textarea.value = '';
|
||||
updateStats();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for gallery view
|
||||
*/
|
||||
export function setupGalleryHandlers(container, config) {
|
||||
const items = container.querySelectorAll('.gallery-item');
|
||||
const deleteBtns = container.querySelectorAll('.gallery-delete-btn');
|
||||
|
||||
if (items && config.onItemClick) {
|
||||
items.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
// Don't trigger if delete button was clicked
|
||||
if (e.target.classList.contains('gallery-delete-btn')) return;
|
||||
|
||||
const idx = parseInt(item.dataset.itemIdx);
|
||||
config.onItemClick(config.items[idx], idx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (deleteBtns && config.onDelete) {
|
||||
deleteBtns.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(btn.dataset.itemIdx);
|
||||
if (confirm('Delete this item?')) {
|
||||
config.onDelete(config.items[idx], idx);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for form view
|
||||
*/
|
||||
export function setupFormHandlers(container, config) {
|
||||
const form = container.querySelector('#config-form');
|
||||
const cancelBtn = container.querySelector('#form-cancel-btn');
|
||||
|
||||
if (form && config.onSubmit) {
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
config.onSubmit(data);
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
form.reset();
|
||||
});
|
||||
}
|
||||
}
|
||||
101
admin-ui/js/utils/url-builder.js
Normal file
101
admin-ui/js/utils/url-builder.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* url-builder.js
|
||||
* Utility for building URLs based on admin settings and project context
|
||||
* Handles dynamic Storybook URL generation with hostname, port, skin
|
||||
*/
|
||||
|
||||
import { useAdminStore } from '../stores/admin-store.js';
|
||||
import { useProjectStore } from '../stores/project-store.js';
|
||||
|
||||
class URLBuilder {
|
||||
/**
|
||||
* Get Storybook URL for current context
|
||||
* @param {Object} options - Options for URL generation
|
||||
* @param {string} options.skin - Skin/theme name (optional, uses project default)
|
||||
* @param {string} options.projectId - Project ID (optional, uses current project)
|
||||
* @returns {string} Full Storybook URL
|
||||
*/
|
||||
static getStorybookUrl(options = {}) {
|
||||
const adminStore = useAdminStore();
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
const skin = options.skin || projectStore.getCurrentProject()?.skinSelected || 'default';
|
||||
const projectId = options.projectId || projectStore.currentProjectId;
|
||||
|
||||
const protocol = adminStore.state.isRemote ? 'https' : 'http';
|
||||
const hostname = adminStore.state.hostname;
|
||||
const port = adminStore.state.port;
|
||||
|
||||
const baseUrl = `${protocol}://${hostname}:${port}/storybook/`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (skin) params.append('skin', skin);
|
||||
if (projectId) params.append('project', projectId);
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API base URL for backend
|
||||
* @returns {string} API base URL
|
||||
*/
|
||||
static getAPIBaseUrl() {
|
||||
const adminStore = useAdminStore();
|
||||
const protocol = adminStore.state.isRemote ? 'https' : 'http';
|
||||
const hostname = adminStore.state.hostname;
|
||||
|
||||
return `${protocol}://${hostname}:3456/api`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Jira API URL (depends on backend for authentication)
|
||||
* @returns {string} Jira API endpoint URL
|
||||
*/
|
||||
static getJiraApiUrl() {
|
||||
const baseUrl = this.getAPIBaseUrl();
|
||||
return `${baseUrl}/integrations/jira`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Figma API URL (depends on backend for authentication)
|
||||
* @returns {string} Figma API endpoint URL
|
||||
*/
|
||||
static getFigmaApiUrl() {
|
||||
const baseUrl = this.getAPIBaseUrl();
|
||||
return `${baseUrl}/integrations/figma`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP bridge URL for tool execution
|
||||
* @returns {string} MCP bridge endpoint URL
|
||||
*/
|
||||
static getMCPBridgeUrl() {
|
||||
const baseUrl = this.getAPIBaseUrl();
|
||||
return `${baseUrl}/mcp-bridge`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a link to a component in Storybook
|
||||
* @param {Object} component - Component info
|
||||
* @param {string} component.id - Component ID
|
||||
* @param {string} component.name - Component name
|
||||
* @returns {string} Link to Storybook component
|
||||
*/
|
||||
static getComponentUrl(component) {
|
||||
const baseUrl = this.getStorybookUrl();
|
||||
const componentPath = `${component.id}--${component.name}`.toLowerCase().replace(/\s+/g, '-');
|
||||
return `${baseUrl}?path=/story/${componentPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a link to theme/skin preview
|
||||
* @param {string} skin - Skin name
|
||||
* @returns {string} Link to Storybook with selected skin
|
||||
*/
|
||||
static getSkinPreviewUrl(skin) {
|
||||
return this.getStorybookUrl({ skin });
|
||||
}
|
||||
}
|
||||
|
||||
export default URLBuilder;
|
||||
Reference in New Issue
Block a user