- 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>
1670 lines
50 KiB
JavaScript
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;
|
|
|
|
})();
|