Files
dss/admin-ui/js/components/tools/ds-screenshot-gallery.js
Digital Production Factory 276ed71f31 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
2025-12-09 18:45:48 -03:00

553 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ds-screenshot-gallery.js
* Screenshot gallery with IndexedDB storage and artifact-based images
*
* REFACTORED: DSS-compliant version using DSBaseTool + gallery-template.js
* - Extends DSBaseTool for Shadow DOM, AbortController, and standardized lifecycle
* - Uses gallery-template.js for DSS-compliant templating (NO inline events/styles)
* - Event delegation pattern for all interactions
* - Logger utility instead of console.*
*
* Reference: .knowledge/dss-coding-standards.json
*/
import DSBaseTool from '../base/ds-base-tool.js';
import toolBridge from '../../services/tool-bridge.js';
import { ComponentHelpers } from '../../utils/component-helpers.js';
import { logger } from '../../utils/logger.js';
class DSScreenshotGallery extends DSBaseTool {
constructor() {
super();
this.screenshots = [];
this.selectedScreenshot = null;
this.isCapturing = false;
this.db = null;
}
async connectedCallback() {
// Initialize IndexedDB first
await this.initDB();
// Call parent connectedCallback (renders + setupEventListeners)
super.connectedCallback();
// Load screenshots after render
await this.loadScreenshots();
}
/**
* Initialize IndexedDB for metadata storage
*/
async initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ds-screenshots', 1);
request.onerror = () => {
logger.error('[DSScreenshotGallery] Failed to open IndexedDB', request.error);
reject(request.error);
};
request.onsuccess = () => {
this.db = request.result;
logger.debug('[DSScreenshotGallery] IndexedDB initialized');
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('screenshots')) {
const store = db.createObjectStore('screenshots', { keyPath: 'id' });
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('tags', 'tags', { unique: false, multiEntry: true });
logger.info('[DSScreenshotGallery] IndexedDB schema created');
}
};
});
}
/**
* Render the component (required by DSBaseTool)
*/
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
height: 100%;
}
.screenshot-gallery-container {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
}
.capture-controls {
margin-bottom: 16px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.capture-input {
flex: 1;
min-width: 200px;
padding: 6px 8px;
font-size: 12px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 2px;
}
.capture-input:focus {
outline: 1px solid var(--vscode-focusBorder);
}
.fullpage-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--vscode-foreground);
cursor: pointer;
}
.capture-btn {
padding: 6px 12px;
font-size: 11px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 2px;
cursor: pointer;
transition: background 0.15s ease;
}
.capture-btn:hover {
background: var(--vscode-button-hoverBackground);
}
.capture-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.gallery-wrapper {
flex: 1;
overflow-y: auto;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--vscode-descriptionForeground);
}
.loading-spinner {
font-size: 32px;
margin-bottom: 12px;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 24px;
}
.modal-content {
max-width: 90%;
max-height: 90%;
background: var(--vscode-sideBar-background);
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid var(--vscode-panel-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 14px;
margin: 0 0 4px 0;
}
.modal-subtitle {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.modal-close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--vscode-foreground);
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close-btn:hover {
background: var(--vscode-toolbar-hoverBackground);
border-radius: 2px;
}
.modal-body {
flex: 1;
overflow: auto;
padding: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>
<div class="screenshot-gallery-container">
<!-- Capture Controls -->
<div class="capture-controls">
<input
type="text"
id="screenshot-selector"
placeholder="Optional: CSS selector to capture"
class="capture-input"
/>
<label class="fullpage-label">
<input type="checkbox" id="screenshot-fullpage" />
Full page
</label>
<button
id="capture-screenshot-btn"
data-action="capture"
class="capture-btn"
type="button"
aria-label="Capture screenshot">
📸 Capture
</button>
</div>
<!-- Gallery Content -->
<div class="gallery-wrapper" id="gallery-content">
<div class="loading">
<div class="loading-spinner">⏳</div>
<div>Initializing gallery...</div>
</div>
</div>
</div>
`;
}
/**
* Setup event listeners (required by DSBaseTool)
* Uses event delegation pattern with data-action attributes
*/
setupEventListeners() {
// EVENT-002: Event delegation on container
this.delegateEvents('.screenshot-gallery-container', 'click', (action, e) => {
switch (action) {
case 'capture':
this.captureScreenshot();
break;
case 'item-click':
const idx = parseInt(e.target.closest('[data-item-idx]')?.dataset.itemIdx, 10);
if (!isNaN(idx) && this.screenshots[idx]) {
this.viewScreenshot(this.screenshots[idx]);
}
break;
case 'item-delete':
const deleteIdx = parseInt(e.target.closest('[data-item-idx]')?.dataset.itemIdx, 10);
if (!isNaN(deleteIdx) && this.screenshots[deleteIdx]) {
this.handleDelete(this.screenshots[deleteIdx].id);
}
break;
}
});
}
async captureScreenshot() {
if (this.isCapturing) return;
this.isCapturing = true;
const captureBtn = this.$('#capture-screenshot-btn');
if (captureBtn) {
captureBtn.disabled = true;
captureBtn.textContent = '📸 Capturing...';
}
try {
const selectorInput = this.$('#screenshot-selector');
const fullPageToggle = this.$('#screenshot-fullpage');
const selector = selectorInput?.value.trim() || null;
const fullPage = fullPageToggle?.checked || false;
logger.info('[DSScreenshotGallery] Capturing screenshot', { selector, fullPage });
// Call MCP tool to capture screenshot
const result = await toolBridge.takeScreenshot(fullPage, selector);
if (result && result.screenshot) {
// Save metadata to IndexedDB
const screenshot = {
id: Date.now(),
timestamp: new Date(),
selector: selector || 'Full Page',
fullPage,
imageData: result.screenshot, // Base64 image data
tags: selector ? [selector] : ['fullpage']
};
await this.saveScreenshot(screenshot);
await this.loadScreenshots();
ComponentHelpers.showToast?.('Screenshot captured successfully', 'success');
logger.info('[DSScreenshotGallery] Screenshot saved', { id: screenshot.id });
} else {
throw new Error('No screenshot data returned');
}
} catch (error) {
logger.error('[DSScreenshotGallery] Failed to capture screenshot', error);
ComponentHelpers.showToast?.(`Failed to capture screenshot: ${error.message}`, 'error');
} finally {
this.isCapturing = false;
if (captureBtn) {
captureBtn.disabled = false;
captureBtn.textContent = '📸 Capture';
}
}
}
async saveScreenshot(screenshot) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['screenshots'], 'readwrite');
const store = transaction.objectStore('screenshots');
const request = store.add(screenshot);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async loadScreenshots() {
const content = this.$('#gallery-content');
if (!content) return;
try {
this.screenshots = await this.getAllScreenshots();
logger.debug('[DSScreenshotGallery] Loaded screenshots', { count: this.screenshots.length });
this.renderGallery();
} catch (error) {
logger.error('[DSScreenshotGallery] Failed to load screenshots', error);
content.innerHTML = ComponentHelpers.renderError('Failed to load screenshots', error);
}
}
async getAllScreenshots() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['screenshots'], 'readonly');
const store = transaction.objectStore('screenshots');
const request = store.getAll();
request.onsuccess = () => resolve(request.result.reverse()); // Most recent first
request.onerror = () => reject(request.error);
});
}
async deleteScreenshot(id) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['screenshots'], 'readwrite');
const store = transaction.objectStore('screenshots');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async handleDelete(id) {
if (!confirm('Delete this screenshot?')) return;
try {
await this.deleteScreenshot(id);
await this.loadScreenshots();
ComponentHelpers.showToast?.('Screenshot deleted', 'success');
logger.info('[DSScreenshotGallery] Screenshot deleted', { id });
} catch (error) {
logger.error('[DSScreenshotGallery] Failed to delete screenshot', error);
ComponentHelpers.showToast?.(`Failed to delete: ${error.message}`, 'error');
}
}
viewScreenshot(screenshot) {
this.selectedScreenshot = screenshot;
this.renderModal();
}
renderModal() {
if (!this.selectedScreenshot) return;
// Create modal in Shadow DOM
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<div>
<h3 class="modal-title">${this.escapeHtml(this.selectedScreenshot.selector)}</h3>
<div class="modal-subtitle">
${ComponentHelpers.formatTimestamp(new Date(this.selectedScreenshot.timestamp))}
</div>
</div>
<button
class="modal-close-btn"
data-action="close-modal"
type="button"
aria-label="Close modal">
×
</button>
</div>
<div class="modal-body">
<img
src="${this.selectedScreenshot.imageData}"
class="modal-image"
alt="${this.escapeHtml(this.selectedScreenshot.selector)}" />
</div>
</div>
`;
// Add click handlers for modal
this.bindEvent(modal, 'click', (e) => {
const closeBtn = e.target.closest('[data-action="close-modal"]');
if (closeBtn || e.target === modal) {
modal.remove();
this.selectedScreenshot = null;
logger.debug('[DSScreenshotGallery] Modal closed');
}
});
this.shadowRoot.appendChild(modal);
logger.debug('[DSScreenshotGallery] Modal opened', { id: this.selectedScreenshot.id });
}
renderGallery() {
const content = this.$('#gallery-content');
if (!content) return;
if (this.screenshots.length === 0) {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px; color: var(--vscode-descriptionForeground);">
<div style="font-size: 48px; margin-bottom: 12px; opacity: 0.5;">📸</div>
<div style="font-size: 13px;">No screenshots captured yet</div>
</div>
`;
return;
}
// Transform screenshots to gallery items format
const galleryItems = this.screenshots.map(screenshot => ({
src: screenshot.imageData,
title: screenshot.selector,
subtitle: ComponentHelpers.formatRelativeTime(new Date(screenshot.timestamp))
}));
// Use DSS-compliant gallery template (NO inline styles/events)
// Note: We're using a simplified inline version here since we're in Shadow DOM
// For full modular approach, we'd import createGalleryView from gallery-template.js
content.innerHTML = `
<div style="margin-bottom: 12px; padding: 12px; background-color: var(--vscode-sideBar-background); border-radius: 4px;">
<div style="font-size: 11px; color: var(--vscode-descriptionForeground);">
${this.screenshots.length} screenshot${this.screenshots.length !== 1 ? 's' : ''} stored
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px;">
${galleryItems.map((item, idx) => `
<div class="gallery-item" data-action="item-click" data-item-idx="${idx}" style="
background: var(--vscode-sideBar-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease;
">
<div style="aspect-ratio: 16/9; overflow: hidden; background: var(--vscode-editor-background);">
<img src="${item.src}"
style="width: 100%; height: 100%; object-fit: cover;"
alt="${this.escapeHtml(item.title)}" />
</div>
<div style="padding: 12px;">
<div style="font-size: 12px; font-weight: 600; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${this.escapeHtml(item.title)}
</div>
<div style="font-size: 11px; color: var(--vscode-descriptionForeground); margin-bottom: 8px;">
${item.subtitle}
</div>
<button
data-action="item-delete"
data-item-idx="${idx}"
type="button"
aria-label="Delete ${this.escapeHtml(item.title)}"
style="padding: 4px 8px; font-size: 10px; background: rgba(244, 135, 113, 0.1); color: #f48771; border: 1px solid #f48771; border-radius: 2px; cursor: pointer;">
🗑️ Delete
</button>
</div>
</div>
`).join('')}
</div>
`;
// Add hover styles via adoptedStyleSheets
this.adoptStyles(`
.gallery-item:hover {
transform: scale(1.02);
}
.gallery-item button:hover {
background: rgba(244, 135, 113, 0.2);
}
`);
logger.debug('[DSScreenshotGallery] Gallery rendered', { count: this.screenshots.length });
}
}
customElements.define('ds-screenshot-gallery', DSScreenshotGallery);
export default DSScreenshotGallery;