/** * GAZE Gallery Enhancement System * Core Controller Module * * Manages gallery views, slideshow modes, and viewer interactions. * Provides a unified API for all gallery features. */ (function() { 'use strict'; // ============================================================ // Configuration & Constants // ============================================================ const CONFIG = { // Layout settings layouts: { grid: { minWidth: 160, maxWidth: 210, gap: 8 }, masonry: { columnWidth: 280, gap: 12, minColumns: 2, maxColumns: 6 }, justified: { targetHeight: 240, minHeight: 180, maxHeight: 320, gap: 8 }, mosaic: { columnWidth: 200, minColumns: 3, maxColumns: 8 } }, // Slideshow settings slideshow: { intervals: [3000, 5000, 8000, 15000], defaultInterval: 5000, transitions: ['fade', 'slide', 'zoom', 'cube'], defaultTransition: 'fade' }, // Viewer settings viewer: { zoomMin: 0.5, zoomMax: 5, zoomStep: 0.1, panSensitivity: 1, preloadCount: 2 }, // Animation durations (ms) animations: { transition: 300, kenBurns: 8000, ambient: 500 }, // Storage keys storage: { viewMode: 'gaze-gallery-view', slideshowSettings: 'gaze-gallery-slideshow', favorites: 'gaze-gallery-favorites' } }; // ============================================================ // Gallery State // ============================================================ const state = { // Current view mode viewMode: 'grid', // grid, masonry, justified, mosaic // Slideshow state slideshow: { active: false, mode: null, // cinema, classic, showcase, ambient currentIndex: 0, interval: CONFIG.slideshow.defaultInterval, transition: CONFIG.slideshow.defaultTransition, timer: null, paused: false, shuffled: false, shuffleOrder: [] }, // Image data images: [], filteredImages: [], // Viewer state viewer: { open: false, currentIndex: 0, zoom: 1, panX: 0, panY: 0, isDragging: false }, // Comparison mode comparison: { active: false, imageA: null, imageB: null, mode: 'slider' // slider, split, onion, difference }, // Discovery mode discovery: { discovered: new Set(), favorites: new Set(), sessionStart: null }, // DOM references dom: { container: null, controls: null } }; // ============================================================ // Event Emitter // ============================================================ const events = { listeners: {}, on(event, callback) { if (!this.listeners[event]) this.listeners[event] = []; this.listeners[event].push(callback); return () => this.off(event, callback); }, off(event, callback) { if (!this.listeners[event]) return; this.listeners[event] = this.listeners[event].filter(cb => cb !== callback); }, emit(event, data) { if (!this.listeners[event]) return; this.listeners[event].forEach(cb => cb(data)); } }; // ============================================================ // Image Data Manager // ============================================================ const imageManager = { /** * Initialize with gallery cards from DOM */ initFromDOM(container) { const cards = container.querySelectorAll('.gallery-card'); state.images = Array.from(cards).map((card, index) => ({ index, src: card.dataset.src, path: card.dataset.path, category: card.dataset.category, slug: card.dataset.slug, name: card.dataset.name, element: card, loaded: false, aspectRatio: null })); state.filteredImages = [...state.images]; return state.images; }, /** * Get image by index */ get(index) { return state.filteredImages[index] || null; }, /** * Get total count */ count() { return state.filteredImages.length; }, /** * Preload images around current index */ async preload(centerIndex, count = CONFIG.viewer.preloadCount) { const total = state.filteredImages.length; const toLoad = []; for (let i = -count; i <= count; i++) { let idx = (centerIndex + i + total) % total; const img = state.filteredImages[idx]; if (img && !img.loaded) { toLoad.push(this.loadImage(img)); } } await Promise.all(toLoad); }, /** * Load single image and get dimensions */ loadImage(imageData) { return new Promise((resolve) => { if (imageData.loaded) { resolve(imageData); return; } const img = new Image(); img.onload = () => { imageData.loaded = true; imageData.aspectRatio = img.width / img.height; imageData.width = img.width; imageData.height = img.height; resolve(imageData); }; img.onerror = () => { imageData.loaded = true; imageData.aspectRatio = 1; resolve(imageData); }; img.src = imageData.src; }); }, /** * Filter images by criteria */ filter(criteria) { if (!criteria || Object.keys(criteria).length === 0) { state.filteredImages = [...state.images]; } else { state.filteredImages = state.images.filter(img => { if (criteria.category && img.category !== criteria.category) return false; if (criteria.slug && img.slug !== criteria.slug) return false; return true; }); } events.emit('imagesFiltered', state.filteredImages); return state.filteredImages; }, /** * Get random unvisited image (for discovery mode) */ getRandomUnvisited() { const unvisited = state.filteredImages.filter( (_, i) => !state.discovery.discovered.has(i) ); if (unvisited.length === 0) { // Reset discovery state.discovery.discovered.clear(); return state.filteredImages[Math.floor(Math.random() * state.filteredImages.length)]; } return unvisited[Math.floor(Math.random() * unvisited.length)]; } }; // ============================================================ // Layout Engine // ============================================================ const layoutEngine = { /** * Switch to a layout mode */ setLayout(mode) { if (!['grid', 'masonry', 'justified', 'mosaic'].includes(mode)) { console.warn(`Unknown layout mode: ${mode}`); return; } state.viewMode = mode; localStorage.setItem(CONFIG.storage.viewMode, mode); // Remove all layout classes const container = state.dom.container; if (container) { container.classList.remove('layout-grid', 'layout-masonry', 'layout-justified', 'layout-mosaic'); container.classList.add(`layout-${mode}`); } // Apply layout this.apply(mode); events.emit('layoutChanged', mode); }, /** * Apply current layout */ apply(mode = state.viewMode) { switch (mode) { case 'grid': this.applyGrid(); break; case 'masonry': this.applyMasonry(); break; case 'justified': this.applyJustified(); break; case 'mosaic': this.applyMosaic(); break; } }, /** * Grid layout (enhanced default) */ applyGrid() { const container = state.dom.container; if (!container) return; // Reset container styles set by other layouts container.style.height = ''; container.style.position = ''; container.style.gap = ''; // Reset any custom positioning on cards and their children state.images.forEach(img => { if (img.element) { img.element.style.cssText = ''; // Reset child element inline styles set by mosaic/other layouts const imgEl = img.element.querySelector('img'); if (imgEl) imgEl.style.cssText = ''; const badge = img.element.querySelector('.cat-badge'); if (badge) badge.style.cssText = ''; const overlay = img.element.querySelector('.overlay'); if (overlay) overlay.style.cssText = ''; } }); }, /** * Masonry layout */ async applyMasonry() { const container = state.dom.container; if (!container) return; const config = CONFIG.layouts.masonry; const containerWidth = container.offsetWidth; const columnCount = Math.min( config.maxColumns, Math.max(config.minColumns, Math.floor(containerWidth / config.columnWidth)) ); const columnWidth = (containerWidth - (columnCount - 1) * config.gap) / columnCount; // Track column heights const columnHeights = new Array(columnCount).fill(0); // Position each image for (const imgData of state.filteredImages) { if (!imgData.element) continue; // Load image to get aspect ratio if (!imgData.loaded) { await imageManager.loadImage(imgData); } // Find shortest column const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights)); const left = shortestColumn * (columnWidth + config.gap); const top = columnHeights[shortestColumn]; // Calculate height based on aspect ratio const height = columnWidth / (imgData.aspectRatio || 1); // Position element imgData.element.style.cssText = ` position: absolute; left: ${left}px; top: ${top}px; width: ${columnWidth}px; height: auto; `; // Update column height columnHeights[shortestColumn] += height + config.gap; } // Set container height container.style.height = `${Math.max(...columnHeights)}px`; container.style.position = 'relative'; }, /** * Justified layout (row-based) */ async applyJustified() { const container = state.dom.container; if (!container) return; const config = CONFIG.layouts.justified; const containerWidth = container.offsetWidth; // Load all images first to get aspect ratios await Promise.all(state.filteredImages.map(img => imageManager.loadImage(img))); // Build rows using linear partition const rows = []; let currentRow = []; let currentRowWidth = 0; for (const imgData of state.filteredImages) { const scaledWidth = config.targetHeight * (imgData.aspectRatio || 1); if (currentRowWidth + scaledWidth > containerWidth && currentRow.length > 0) { rows.push(this.justifyRow(currentRow, containerWidth, config)); currentRow = []; currentRowWidth = 0; } currentRow.push(imgData); currentRowWidth += scaledWidth + config.gap; } // Handle last row if (currentRow.length > 0) { rows.push(this.justifyRow(currentRow, containerWidth, config, true)); } // Position images let top = 0; for (const row of rows) { let left = 0; for (const item of row.items) { item.imgData.element.style.cssText = ` position: absolute; left: ${left}px; top: ${top}px; width: ${item.width}px; height: ${row.height}px; `; item.imgData.element.querySelector('img').style.cssText = ` width: 100%; height: 100%; object-fit: cover; `; left += item.width + config.gap; } top += row.height + config.gap; } container.style.height = `${top}px`; container.style.position = 'relative'; }, /** * Calculate justified row dimensions */ justifyRow(images, containerWidth, config, isLast = false) { const gap = config.gap; const totalGap = (images.length - 1) * gap; const availableWidth = containerWidth - totalGap; // Calculate row aspect ratio const rowAspectRatio = images.reduce((sum, img) => sum + (img.aspectRatio || 1), 0); let rowHeight = availableWidth / rowAspectRatio; // Clamp height rowHeight = Math.min(config.maxHeight, Math.max(config.minHeight, rowHeight)); // For last row, don't stretch if too few images if (isLast && images.length < 3) { rowHeight = config.targetHeight; } return { height: rowHeight, items: images.map(imgData => ({ imgData, width: rowHeight * (imgData.aspectRatio || 1) })) }; }, /** * Mosaic layout - seamless grid with no gaps or rounded corners */ async applyMosaic() { const container = state.dom.container; if (!container) return; const config = CONFIG.layouts.mosaic; const containerWidth = container.offsetWidth; // Calculate column count based on container width const columnCount = Math.min( config.maxColumns, Math.max(config.minColumns, Math.floor(containerWidth / config.columnWidth)) ); const itemWidth = containerWidth / columnCount; const itemHeight = itemWidth; // Square tiles (1:1 aspect ratio) // Position each image in a strict grid state.filteredImages.forEach((imgData, index) => { if (!imgData.element) return; const col = index % columnCount; const row = Math.floor(index / columnCount); imgData.element.style.cssText = ` position: absolute; left: ${col * itemWidth}px; top: ${row * itemHeight}px; width: ${itemWidth}px; height: ${itemHeight}px; margin: 0; padding: 0; border-radius: 0; `; // Ensure images fill completely with no gaps const imgEl = imgData.element.querySelector('img'); if (imgEl) { imgEl.style.cssText = ` width: 100%; height: 100%; object-fit: cover; border-radius: 0; display: block; `; } // Hide badges and overlays for seamless look const badge = imgData.element.querySelector('.cat-badge'); if (badge) badge.style.display = 'none'; const overlay = imgData.element.querySelector('.overlay'); if (overlay) overlay.style.opacity = '0'; }); // Calculate total height const rowCount = Math.ceil(state.filteredImages.length / columnCount); container.style.height = `${rowCount * itemHeight}px`; container.style.position = 'relative'; container.style.gap = '0'; } }; // ============================================================ // Enhanced Viewer (Lightbox) // ============================================================ const viewer = { /** * Open viewer at specific index */ open(index) { state.viewer.currentIndex = index; state.viewer.open = true; state.viewer.zoom = 1; state.viewer.panX = 0; state.viewer.panY = 0; this.render(); this.show(); imageManager.preload(index); events.emit('viewerOpened', { index, image: imageManager.get(index) }); }, /** * Close viewer */ close() { state.viewer.open = false; this.hide(); events.emit('viewerClosed'); }, /** * Navigate to next/previous */ navigate(direction) { const total = imageManager.count(); if (total === 0) return; let newIndex = state.viewer.currentIndex + direction; if (newIndex < 0) newIndex = total - 1; if (newIndex >= total) newIndex = 0; state.viewer.currentIndex = newIndex; state.viewer.zoom = 1; state.viewer.panX = 0; state.viewer.panY = 0; this.render(); imageManager.preload(newIndex); events.emit('viewerNavigated', { index: newIndex, image: imageManager.get(newIndex) }); }, /** * Zoom to level */ setZoom(level, centerX, centerY) { const oldZoom = state.viewer.zoom; state.viewer.zoom = Math.min(CONFIG.viewer.zoomMax, Math.max(CONFIG.viewer.zoomMin, level)); // Adjust pan to zoom toward center point if (centerX !== undefined && centerY !== undefined) { const scale = state.viewer.zoom / oldZoom; state.viewer.panX = centerX - (centerX - state.viewer.panX) * scale; state.viewer.panY = centerY - (centerY - state.viewer.panY) * scale; } this.applyTransform(); events.emit('viewerZoomed', state.viewer.zoom); }, /** * Handle zoom wheel */ handleWheel(e) { e.preventDefault(); const delta = e.deltaY > 0 ? -CONFIG.viewer.zoomStep : CONFIG.viewer.zoomStep; const rect = e.currentTarget.getBoundingClientRect(); const centerX = e.clientX - rect.left - rect.width / 2; const centerY = e.clientY - rect.top - rect.height / 2; this.setZoom(state.viewer.zoom + delta, centerX, centerY); }, /** * Pan the image */ pan(dx, dy) { if (state.viewer.zoom <= 1) return; state.viewer.panX += dx * CONFIG.viewer.panSensitivity; state.viewer.panY += dy * CONFIG.viewer.panSensitivity; this.applyTransform(); }, /** * Reset zoom and pan */ resetTransform() { state.viewer.zoom = 1; state.viewer.panX = 0; state.viewer.panY = 0; this.applyTransform(); }, /** * Apply current transform to image */ applyTransform() { const img = document.getElementById('gaze-viewer-image'); if (!img) return; img.style.transform = `translate(${state.viewer.panX}px, ${state.viewer.panY}px) scale(${state.viewer.zoom})`; }, /** * Create/update viewer DOM */ render() { let modal = document.getElementById('gaze-viewer'); if (!modal) { modal = this.createDOM(); } const imgData = imageManager.get(state.viewer.currentIndex); if (!imgData) return; // Update image const img = document.getElementById('gaze-viewer-image'); img.src = imgData.src; img.alt = imgData.name || 'Gallery image'; // Update counter const counter = document.getElementById('gaze-viewer-counter'); counter.textContent = `${state.viewer.currentIndex + 1} / ${imageManager.count()}`; // Update info const info = document.getElementById('gaze-viewer-info-content'); info.innerHTML = `
${imgData.name || 'Unknown'}
${imgData.category || ''} ${imgData.slug ? `${imgData.slug}` : ''}
`; // Update Open Button Link const openBtn = document.getElementById('gaze-viewer-open-btn'); if (openBtn) { if (imgData.category === 'characters') { openBtn.onclick = () => window.location.href = '/character/' + imgData.slug; openBtn.textContent = 'Open'; } else if (imgData.category === 'checkpoints') { openBtn.onclick = () => window.location.href = '/checkpoint/' + imgData.slug; openBtn.textContent = 'Open'; } else { openBtn.onclick = () => window.location.href = '/generator?' + imgData.category.replace(/s$/, '') + '=' + encodeURIComponent(imgData.slug); openBtn.textContent = 'Generator'; } } // Reset transform this.applyTransform(); }, /** * Create viewer DOM structure */ createDOM() { const modal = document.createElement('div'); modal.id = 'gaze-viewer'; modal.className = 'gaze-viewer'; modal.innerHTML = `
1 / 1
100%
`; document.body.appendChild(modal); this.attachEventListeners(modal); return modal; }, /** * Attach event listeners to viewer */ attachEventListeners(modal) { // Close button modal.querySelector('.gaze-viewer-close').addEventListener('click', () => this.close()); // Backdrop click modal.querySelector('.gaze-viewer-backdrop').addEventListener('click', () => this.close()); // Navigation modal.querySelector('.gaze-viewer-prev').addEventListener('click', () => this.navigate(-1)); modal.querySelector('.gaze-viewer-next').addEventListener('click', () => this.navigate(1)); // Zoom controls modal.querySelector('#gaze-viewer-zoom-in').addEventListener('click', () => { this.setZoom(state.viewer.zoom + 0.25); }); modal.querySelector('#gaze-viewer-zoom-out').addEventListener('click', () => { this.setZoom(state.viewer.zoom - 0.25); }); modal.querySelector('#gaze-viewer-reset').addEventListener('click', () => { this.resetTransform(); }); // Info toggle modal.querySelector('#gaze-viewer-info-toggle').addEventListener('click', () => { modal.querySelector('.gaze-viewer-info').classList.toggle('open'); }); // Image wheel zoom const img = modal.querySelector('.gaze-viewer-img'); img.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false }); // Image drag to pan let isDragging = false; let lastX, lastY; img.addEventListener('mousedown', (e) => { if (state.viewer.zoom > 1) { isDragging = true; lastX = e.clientX; lastY = e.clientY; img.style.cursor = 'grabbing'; } }); document.addEventListener('mousemove', (e) => { if (isDragging) { const dx = e.clientX - lastX; const dy = e.clientY - lastY; this.pan(dx, dy); lastX = e.clientX; lastY = e.clientY; } }); document.addEventListener('mouseup', () => { isDragging = false; img.style.cursor = state.viewer.zoom > 1 ? 'grab' : 'zoom-in'; }); // Double-click to toggle zoom img.addEventListener('dblclick', () => { if (state.viewer.zoom > 1) { this.resetTransform(); } else { this.setZoom(2); } }); // Slideshow button modal.querySelector('#gaze-viewer-slideshow').addEventListener('click', () => { slideshowController.start('classic'); }); // Prompt button modal.querySelector('#gaze-viewer-prompt-btn').addEventListener('click', () => { const imgData = imageManager.get(state.viewer.currentIndex); if (imgData && window.showPrompt) { window.showPrompt(imgData.path, imgData.name, imgData.category, imgData.slug); } }); // Delete button modal.querySelector('#gaze-viewer-delete-btn').addEventListener('click', () => { const imgData = imageManager.get(state.viewer.currentIndex); if (imgData && window.openDeleteModal) { window.openDeleteModal(imgData.path, imgData.name); viewer.close(); } }); }, /** * Show the viewer */ show() { const modal = document.getElementById('gaze-viewer'); if (modal) { modal.classList.add('open'); document.body.style.overflow = 'hidden'; } }, /** * Hide the viewer */ hide() { const modal = document.getElementById('gaze-viewer'); if (modal) { modal.classList.remove('open'); document.body.style.overflow = ''; } }, /** * Build thumbnail strip */ buildThumbnails() { const track = document.getElementById('gaze-viewer-thumbs-track'); if (!track) return; track.innerHTML = state.filteredImages.map((img, i) => `
`).join(''); // Attach click handlers track.querySelectorAll('.gaze-viewer-thumb').forEach(thumb => { thumb.addEventListener('click', () => { const index = parseInt(thumb.dataset.index); state.viewer.currentIndex = index; this.render(); this.updateThumbnailActive(); }); }); }, /** * Update active thumbnail */ updateThumbnailActive() { const track = document.getElementById('gaze-viewer-thumbs-track'); if (!track) return; track.querySelectorAll('.gaze-viewer-thumb').forEach((thumb, i) => { thumb.classList.toggle('active', i === state.viewer.currentIndex); }); // Scroll active into view const active = track.querySelector('.gaze-viewer-thumb.active'); if (active) { active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); } } }; // ============================================================ // Slideshow Controller // ============================================================ const slideshowController = { /** * Start slideshow in specified mode */ start(mode = 'classic') { state.slideshow.active = true; state.slideshow.mode = mode; state.slideshow.paused = false; // If viewer isn't open, open it if (!state.viewer.open) { viewer.open(0); } // Setup based on mode switch (mode) { case 'cinema': this.setupCinemaMode(); break; case 'classic': this.setupClassicMode(); break; case 'showcase': this.setupShowcaseMode(); break; case 'ambient': this.setupAmbientMode(); break; } // Start timer this.startTimer(); events.emit('slideshowStarted', { mode }); }, /** * Stop slideshow */ stop() { state.slideshow.active = false; state.slideshow.mode = null; this.stopTimer(); this.cleanupModes(); events.emit('slideshowStopped'); }, /** * Pause/resume */ toggle() { if (!state.slideshow.active) return; state.slideshow.paused = !state.slideshow.paused; if (state.slideshow.paused) { this.stopTimer(); } else { this.startTimer(); } events.emit('slideshowToggled', { paused: state.slideshow.paused }); }, /** * Go to next slide */ next() { if (state.slideshow.shuffled) { // Use shuffle order const currentOrderIndex = state.slideshow.shuffleOrder.indexOf(state.viewer.currentIndex); const nextOrderIndex = (currentOrderIndex + 1) % state.slideshow.shuffleOrder.length; state.viewer.currentIndex = state.slideshow.shuffleOrder[nextOrderIndex]; viewer.render(); } else { viewer.navigate(1); } // Apply transition effect this.applyTransition(); }, /** * Toggle shuffle */ toggleShuffle() { state.slideshow.shuffled = !state.slideshow.shuffled; if (state.slideshow.shuffled) { // Generate shuffle order state.slideshow.shuffleOrder = [...Array(imageManager.count()).keys()] .sort(() => Math.random() - 0.5); } events.emit('slideshowShuffleToggled', { shuffled: state.slideshow.shuffled }); }, /** * Set interval */ setInterval(ms) { state.slideshow.interval = ms; if (state.slideshow.active && !state.slideshow.paused) { this.stopTimer(); this.startTimer(); } }, /** * Set transition type */ setTransition(type) { state.slideshow.transition = type; }, /** * Start auto-advance timer */ startTimer() { this.stopTimer(); state.slideshow.timer = setInterval(() => this.next(), state.slideshow.interval); }, /** * Stop timer */ stopTimer() { if (state.slideshow.timer) { clearInterval(state.slideshow.timer); state.slideshow.timer = null; } }, /** * Apply current transition effect */ applyTransition() { const img = document.getElementById('gaze-viewer-image'); if (!img) return; const transition = state.slideshow.transition; img.classList.remove('transition-fade', 'transition-slide', 'transition-zoom', 'transition-cube'); // Trigger reflow void img.offsetWidth; img.classList.add(`transition-${transition}`); }, /** * Setup Cinema mode */ setupCinemaMode() { const viewerEl = document.getElementById('gaze-viewer'); if (viewerEl) { viewerEl.classList.add('cinema-mode'); // Add ambient glow container if (!document.getElementById('gaze-ambient-glow')) { const glow = document.createElement('div'); glow.id = 'gaze-ambient-glow'; glow.className = 'gaze-ambient-glow'; viewerEl.querySelector('.gaze-viewer-content').prepend(glow); } // Extract colors for ambient glow this.updateAmbientGlow(); } }, /** * Setup Classic mode */ setupClassicMode() { const viewerEl = document.getElementById('gaze-viewer'); if (viewerEl) { viewerEl.classList.add('classic-mode'); } }, /** * Setup Showcase mode */ setupShowcaseMode() { const viewerEl = document.getElementById('gaze-viewer'); if (viewerEl) { viewerEl.classList.add('showcase-mode'); // Add frame element if (!document.getElementById('gaze-showcase-frame')) { const frame = document.createElement('div'); frame.id = 'gaze-showcase-frame'; frame.className = 'gaze-showcase-frame'; viewerEl.querySelector('.gaze-viewer-stage').appendChild(frame); } } }, /** * Setup Ambient mode */ setupAmbientMode() { const viewerEl = document.getElementById('gaze-viewer'); if (viewerEl) { viewerEl.classList.add('ambient-mode'); // Add particle container if (!document.getElementById('gaze-particles')) { const particles = document.createElement('div'); particles.id = 'gaze-particles'; particles.className = 'gaze-particles'; viewerEl.querySelector('.gaze-viewer-content').prepend(particles); // Initialize particles this.createParticles(); } // Longer intervals for ambient state.slideshow.interval = 15000; this.startTimer(); } }, /** * Create floating particles */ createParticles() { const container = document.getElementById('gaze-particles'); if (!container) return; const colors = ['#8b7eff', '#c084fc', '#60a5fa', '#ffffff']; const count = 40; for (let i = 0; i < count; i++) { const particle = document.createElement('div'); particle.className = 'gaze-particle'; particle.style.cssText = ` left: ${Math.random() * 100}%; top: ${Math.random() * 100}%; width: ${2 + Math.random() * 6}px; height: ${2 + Math.random() * 6}px; background: ${colors[Math.floor(Math.random() * colors.length)]}; opacity: ${0.1 + Math.random() * 0.5}; animation-delay: ${Math.random() * 10}s; animation-duration: ${15 + Math.random() * 20}s; `; container.appendChild(particle); } }, /** * Update ambient glow colors from current image */ async updateAmbientGlow() { const imgData = imageManager.get(state.viewer.currentIndex); if (!imgData) return; const glowEl = document.getElementById('gaze-ambient-glow'); if (!glowEl) return; try { const colors = await this.extractDominantColors(imgData.src); glowEl.style.background = ` radial-gradient(ellipse at 20% 20%, ${colors[0]}40 0%, transparent 50%), radial-gradient(ellipse at 80% 20%, ${colors[1]}30 0%, transparent 50%), radial-gradient(ellipse at 50% 80%, ${colors[2]}35 0%, transparent 50%) `; } catch (e) { console.warn('Could not extract colors:', e); } }, /** * Extract dominant colors from image */ extractDominantColors(src) { return new Promise((resolve) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 50; canvas.height = 50; ctx.drawImage(img, 0, 0, 50, 50); const imageData = ctx.getImageData(0, 0, 50, 50).data; const colors = []; // Sample colors from different regions const regions = [ [10, 10], [40, 10], [25, 40] ]; for (const [x, y] of regions) { const i = (y * 50 + x) * 4; colors.push(`rgb(${imageData[i]}, ${imageData[i+1]}, ${imageData[i+2]})`); } resolve(colors); }; img.onerror = () => resolve(['#8b7eff', '#c084fc', '#60a5fa']); img.src = src; }); }, /** * Cleanup mode-specific elements */ cleanupModes() { const viewerEl = document.getElementById('gaze-viewer'); if (viewerEl) { viewerEl.classList.remove('cinema-mode', 'classic-mode', 'showcase-mode', 'ambient-mode'); } // Remove particles const particles = document.getElementById('gaze-particles'); if (particles) particles.remove(); // Remove ambient glow const glow = document.getElementById('gaze-ambient-glow'); if (glow) glow.remove(); // Remove frame const frame = document.getElementById('gaze-showcase-frame'); if (frame) frame.remove(); } }; // ============================================================ // Comparison Mode // ============================================================ const comparisonMode = { /** * Enter comparison mode */ enter(imageA, imageB) { state.comparison.active = true; state.comparison.imageA = imageA; state.comparison.imageB = imageB; this.render(); events.emit('comparisonStarted', { imageA, imageB }); }, /** * Exit comparison mode */ exit() { state.comparison.active = false; this.cleanup(); events.emit('comparisonEnded'); }, /** * Set comparison mode type */ setMode(mode) { state.comparison.mode = mode; this.render(); }, /** * Render comparison view */ render() { let modal = document.getElementById('gaze-comparison'); if (!modal) { modal = this.createDOM(); } const { imageA, imageB, mode } = state.comparison; modal.className = `gaze-comparison mode-${mode}`; modal.classList.add('open'); // Update images document.getElementById('gaze-compare-a').src = imageA.src; document.getElementById('gaze-compare-b').src = imageB.src; document.body.style.overflow = 'hidden'; }, /** * Create comparison DOM */ createDOM() { const modal = document.createElement('div'); modal.id = 'gaze-comparison'; modal.className = 'gaze-comparison'; modal.innerHTML = `
Image A
Image B
`; document.body.appendChild(modal); this.attachEventListeners(modal); return modal; }, /** * Attach event listeners */ attachEventListeners(modal) { // Close modal.querySelector('.gaze-comparison-backdrop').addEventListener('click', () => this.exit()); modal.querySelector('.gaze-compare-close').addEventListener('click', () => this.exit()); // Mode buttons modal.querySelectorAll('.gaze-compare-mode-btn').forEach(btn => { btn.addEventListener('click', () => { modal.querySelectorAll('.gaze-compare-mode-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); this.setMode(btn.dataset.mode); }); }); // Slider drag const divider = modal.querySelector('#gaze-compare-divider'); let isDragging = false; divider.addEventListener('mousedown', () => isDragging = true); document.addEventListener('mouseup', () => isDragging = false); document.addEventListener('mousemove', (e) => { if (!isDragging || state.comparison.mode !== 'slider') return; const rect = modal.querySelector('.gaze-comparison-images').getBoundingClientRect(); const percent = ((e.clientX - rect.left) / rect.width) * 100; const clamped = Math.min(95, Math.max(5, percent)); divider.style.left = `${clamped}%`; modal.querySelector('.gaze-compare-pane-a').style.width = `${clamped}%`; }); // Onion opacity const opacitySlider = modal.querySelector('#gaze-compare-opacity'); opacitySlider.addEventListener('input', () => { const opacity = opacitySlider.value / 100; modal.querySelector('#gaze-compare-b').style.opacity = opacity; }); }, /** * Cleanup */ cleanup() { const modal = document.getElementById('gaze-comparison'); if (modal) { modal.classList.remove('open'); document.body.style.overflow = ''; } } }; // ============================================================ // Keyboard Controller // ============================================================ const keyboardController = { /** * Initialize keyboard shortcuts */ init() { document.addEventListener('keydown', (e) => this.handleKeydown(e)); }, /** * Handle keydown event */ handleKeydown(e) { // Skip if typing in input if (e.target.matches('input, textarea, select')) return; const key = e.key.toLowerCase(); // Viewer shortcuts (when viewer is open) if (state.viewer.open) { switch (key) { case 'escape': if (state.slideshow.active) { slideshowController.stop(); } else { viewer.close(); } e.preventDefault(); break; case 'arrowleft': viewer.navigate(-1); e.preventDefault(); break; case 'arrowright': viewer.navigate(1); e.preventDefault(); break; case 'arrowup': viewer.setZoom(state.viewer.zoom + 0.25); e.preventDefault(); break; case 'arrowdown': viewer.setZoom(state.viewer.zoom - 0.25); e.preventDefault(); break; case ' ': if (state.slideshow.active) { slideshowController.toggle(); } else { slideshowController.start('classic'); } e.preventDefault(); break; case 'f': this.toggleFullscreen(); e.preventDefault(); break; case 'i': document.querySelector('.gaze-viewer-info')?.classList.toggle('open'); e.preventDefault(); break; case '0': viewer.resetTransform(); e.preventDefault(); break; case 'r': if (state.slideshow.active) { slideshowController.toggleShuffle(); } e.preventDefault(); break; } // Number keys 1-5 for slideshow speed if (state.slideshow.active && /[1-5]/.test(key)) { const speeds = [2000, 3000, 5000, 8000, 15000]; slideshowController.setInterval(speeds[parseInt(key) - 1]); e.preventDefault(); } } // Gallery shortcuts (when viewer is closed) if (!state.viewer.open) { switch (key) { case 'g': layoutEngine.setLayout('grid'); e.preventDefault(); break; case 'm': layoutEngine.setLayout('masonry'); e.preventDefault(); break; case 'j': layoutEngine.setLayout('justified'); e.preventDefault(); break; case 'a': layoutEngine.setLayout('mosaic'); e.preventDefault(); break; case 's': viewer.open(0); slideshowController.start('cinema'); e.preventDefault(); break; } } }, /** * Toggle fullscreen */ toggleFullscreen() { if (!document.fullscreenElement) { const viewer = document.getElementById('gaze-viewer'); if (viewer) { viewer.requestFullscreen().catch(console.error); } } else { document.exitFullscreen().catch(console.error); } } }; // ============================================================ // Public API // ============================================================ const GalleryCore = { // Configuration CONFIG, // State (read-only) get state() { return state; }, // Event handling on: events.on.bind(events), off: events.off.bind(events), /** * Initialize the gallery system */ init(containerSelector = '.gallery-grid') { const container = document.querySelector(containerSelector); if (!container) { console.warn('Gallery container not found:', containerSelector); return; } state.dom.container = container; // Load images from DOM imageManager.initFromDOM(container); // Restore saved view mode const savedMode = localStorage.getItem(CONFIG.storage.viewMode); if (savedMode) { state.viewMode = savedMode; } // Initialize keyboard shortcuts keyboardController.init(); // Apply current layout layoutEngine.apply(); // Setup click handlers on cards this.attachCardListeners(); console.log(`Gallery initialized with ${imageManager.count()} images`); events.emit('initialized', { imageCount: imageManager.count() }); return this; }, /** * Attach click listeners to gallery cards */ attachCardListeners() { state.images.forEach((img, index) => { if (img.element) { img.element.addEventListener('click', (e) => { // Don't open viewer if in selection mode const grid = img.element.closest('.gallery-grid'); if (grid && grid.classList.contains('selection-mode')) return; // Don't open viewer if clicking action buttons if (e.target.closest('button, a, input')) return; viewer.open(index); }); } }); }, // Layout methods setLayout: layoutEngine.setLayout.bind(layoutEngine), getLayout: () => state.viewMode, // Viewer methods openViewer: viewer.open.bind(viewer), closeViewer: viewer.close.bind(viewer), // Slideshow methods startSlideshow: slideshowController.start.bind(slideshowController), stopSlideshow: slideshowController.stop.bind(slideshowController), toggleSlideshow: slideshowController.toggle.bind(slideshowController), setSlideshowInterval: slideshowController.setInterval.bind(slideshowController), setSlideshowTransition: slideshowController.setTransition.bind(slideshowController), // Comparison methods startComparison: comparisonMode.enter.bind(comparisonMode), stopComparison: comparisonMode.exit.bind(comparisonMode), // Image management getImage: imageManager.get.bind(imageManager), getImageCount: imageManager.count.bind(imageManager), filterImages: imageManager.filter.bind(imageManager), // Discovery mode getRandomImage: imageManager.getRandomUnvisited.bind(imageManager), markDiscovered: (index) => state.discovery.discovered.add(index), toggleFavorite: (index) => { if (state.discovery.favorites.has(index)) { state.discovery.favorites.delete(index); } else { state.discovery.favorites.add(index); } events.emit('favoriteToggled', { index, isFavorite: state.discovery.favorites.has(index) }); } }; // Expose to global scope window.GalleryCore = GalleryCore; })();