Files
character-browser/static/js/gallery/gallery-core.js
Aodhan Collins 5e4348ebc1 Add extra prompts, endless generation, random character default, and small fixes
- Add extra positive/negative prompt textareas to all 9 detail pages with session persistence
- Add Endless generation button to all detail pages (continuous preview generation until stopped)
- Default character selector to "Random Character" on all secondary detail pages
- Fix queue clear endpoint (remove spurious auth check)
- Refactor app.py into routes/ and services/ modules
- Update CLAUDE.md with new architecture documentation
- Various data file updates and cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:07:16 +00:00

1670 lines
50 KiB
JavaScript

/**
* 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 any custom positioning
state.images.forEach(img => {
if (img.element) {
img.element.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 = `
<div class="viewer-info-title">${imgData.name || 'Unknown'}</div>
<div class="viewer-info-meta">
<span class="viewer-info-category">${imgData.category || ''}</span>
${imgData.slug ? `<span class="viewer-info-slug">${imgData.slug}</span>` : ''}
</div>
`;
// 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 = `
<div class="gaze-viewer-backdrop"></div>
<div class="gaze-viewer-content">
<!-- Main image area -->
<div class="gaze-viewer-stage">
<img id="gaze-viewer-image" src="" alt="" class="gaze-viewer-img">
</div>
<!-- Navigation arrows -->
<button class="gaze-viewer-nav gaze-viewer-prev" aria-label="Previous">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<button class="gaze-viewer-nav gaze-viewer-next" aria-label="Next">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<!-- Top toolbar -->
<div class="gaze-viewer-toolbar">
<div class="gaze-viewer-toolbar-left">
<span id="gaze-viewer-counter" class="gaze-viewer-counter">1 / 1</span>
</div>
<div class="gaze-viewer-toolbar-center">
<button class="gaze-viewer-btn" id="gaze-viewer-zoom-out" aria-label="Zoom out">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</button>
<span id="gaze-viewer-zoom-level" class="gaze-viewer-zoom-level">100%</span>
<button class="gaze-viewer-btn" id="gaze-viewer-zoom-in" aria-label="Zoom in">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</button>
<button class="gaze-viewer-btn" id="gaze-viewer-reset" aria-label="Reset zoom">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</button>
</div>
<div class="gaze-viewer-toolbar-right">
<button class="gaze-viewer-btn" id="gaze-viewer-info-toggle" aria-label="Toggle info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
<button class="gaze-viewer-btn" id="gaze-viewer-slideshow" aria-label="Start slideshow">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
</button>
<button class="gaze-viewer-btn gaze-viewer-close" id="gaze-viewer-close" aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<!-- Info panel -->
<div class="gaze-viewer-info" id="gaze-viewer-info">
<div id="gaze-viewer-info-content"></div>
<div class="gaze-viewer-info-actions">
<button class="btn btn-sm btn-primary" id="gaze-viewer-prompt-btn">View Prompt</button>
<button class="btn btn-sm btn-outline-light" id="gaze-viewer-open-btn">Open</button>
<button class="btn btn-sm btn-outline-danger" id="gaze-viewer-delete-btn">Delete</button>
</div>
</div>
<!-- Thumbnail strip -->
<div class="gaze-viewer-thumbs" id="gaze-viewer-thumbs">
<div class="gaze-viewer-thumbs-track" id="gaze-viewer-thumbs-track"></div>
</div>
</div>
`;
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) => `
<div class="gaze-viewer-thumb ${i === state.viewer.currentIndex ? 'active' : ''}"
data-index="${i}">
<img src="${img.src}" alt="" loading="lazy">
</div>
`).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 = `
<div class="gaze-comparison-backdrop"></div>
<div class="gaze-comparison-content">
<div class="gaze-comparison-images">
<div class="gaze-compare-pane gaze-compare-pane-a">
<img id="gaze-compare-a" src="" alt="Image A">
</div>
<div class="gaze-compare-divider" id="gaze-compare-divider">
<div class="gaze-compare-handle"></div>
</div>
<div class="gaze-compare-pane gaze-compare-pane-b">
<img id="gaze-compare-b" src="" alt="Image B">
</div>
</div>
<div class="gaze-comparison-controls">
<div class="gaze-comparison-modes">
<button class="gaze-compare-mode-btn active" data-mode="slider">Slider</button>
<button class="gaze-compare-mode-btn" data-mode="split">Split</button>
<button class="gaze-compare-mode-btn" data-mode="onion">Onion</button>
</div>
<input type="range" id="gaze-compare-opacity" min="0" max="100" value="50" class="d-none">
<button class="gaze-compare-close">✕ Close</button>
</div>
</div>
`;
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;
})();