import { withCacheBuster } from './utils.js'; import { extractMetadataFromBlob } from './metadata.js'; const FILTER_STORAGE_KEY = 'gemini-app-history-filter'; const SEARCH_STORAGE_KEY = 'gemini-app-history-search'; const FAVORITES_STORAGE_KEY = 'gemini-app-history-favorites'; const SOURCE_STORAGE_KEY = 'gemini-app-history-source'; const VALID_SOURCES = ['generated', 'uploads']; const DEFAULT_STATE = { filter: 'all', search: '', favorites: false, }; function createStateMap(fallback) { return { generated: fallback, uploads: fallback, }; } function parseStoredMap(key, fallback) { const defaultMap = createStateMap(fallback); let raw; try { raw = localStorage.getItem(key); if (!raw) return defaultMap; const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { generated: parsed.generated ?? parsed.default ?? fallback, uploads: parsed.uploads ?? parsed.default ?? fallback, }; } // Backward compatibility: single value string or number return createStateMap(raw); } catch (e) { // If parsing failed but raw exists, treat it as a primitive single value if (raw) { return createStateMap(raw); } console.warn('Failed to load history state', e); return defaultMap; } } function persistStateMap(key, map) { try { localStorage.setItem(key, JSON.stringify(map)); } catch (e) { console.warn('Failed to save history state', e); } } export function createGallery({ galleryGrid, onSelect }) { let currentFilter = DEFAULT_STATE.filter; let searchQuery = DEFAULT_STATE.search; let currentSource = 'generated'; let allImages = []; let favorites = []; let showOnlyFavorites = DEFAULT_STATE.favorites; const stateBySource = { generated: { ...DEFAULT_STATE }, uploads: { ...DEFAULT_STATE }, }; // Load saved filter, search and favorites from localStorage (per source) const savedFilters = parseStoredMap(FILTER_STORAGE_KEY, DEFAULT_STATE.filter); const savedSearches = parseStoredMap(SEARCH_STORAGE_KEY, DEFAULT_STATE.search); const savedFavorites = parseStoredMap(FAVORITES_STORAGE_KEY, DEFAULT_STATE.favorites); stateBySource.generated.filter = savedFilters.generated; stateBySource.uploads.filter = savedFilters.uploads; stateBySource.generated.search = savedSearches.generated; stateBySource.uploads.search = savedSearches.uploads; stateBySource.generated.favorites = savedFavorites.generated; stateBySource.uploads.favorites = savedFavorites.uploads; try { const savedSource = localStorage.getItem(SOURCE_STORAGE_KEY); if (savedSource && VALID_SOURCES.includes(savedSource)) { currentSource = savedSource; } } catch (e) { console.warn('Failed to load history source', e); } function applySourceState(source) { const state = stateBySource[source] || DEFAULT_STATE; currentFilter = state.filter ?? DEFAULT_STATE.filter; searchQuery = state.search ?? DEFAULT_STATE.search; showOnlyFavorites = state.favorites ?? DEFAULT_STATE.favorites; } applySourceState(currentSource); // Load favorites from backend async function loadFavorites() { try { const response = await fetch('/gallery_favorites'); const data = await response.json(); favorites = data.favorites || []; } catch (error) { console.warn('Failed to load gallery favorites', error); } } function extractRelativePath(imageUrl) { if (!imageUrl) return ''; try { const url = new URL(imageUrl, window.location.origin); const path = url.pathname; const staticIndex = path.indexOf('/static/'); if (staticIndex !== -1) { return path.slice(staticIndex + '/static/'.length).replace(/^\//, ''); } return path.replace(/^\//, ''); } catch (error) { const parts = imageUrl.split('/static/'); if (parts[1]) return parts[1].split('?')[0]; return imageUrl.split('/').pop().split('?')[0]; } } function getFavoriteKey(imageUrl) { const relative = extractRelativePath(imageUrl); if (relative) return relative; const filename = imageUrl.split('/').pop().split('?')[0]; return `${currentSource}/${filename}`; } function isFavorite(imageUrl) { const key = getFavoriteKey(imageUrl); const filename = imageUrl.split('/').pop().split('?')[0]; return favorites.includes(key) || favorites.includes(filename); } // Date comparison utilities function getFileTimestamp(imageUrl) { // Extract date from filename format: model_yyyymmdd_id.png // Example: gemini-3-pro-image-preview_20251125_1.png const filename = imageUrl.split('/').pop().split('?')[0]; const match = filename.match(/_(\d{8})_/); // Match yyyymmdd between underscores if (match) { const dateStr = match[1]; // e.g., "20251125" const year = parseInt(dateStr.substring(0, 4), 10); const month = parseInt(dateStr.substring(4, 6), 10) - 1; // Month is 0-indexed const day = parseInt(dateStr.substring(6, 8), 10); return new Date(year, month, day).getTime(); } return null; } function isToday(timestamp) { if (!timestamp) return false; const date = new Date(timestamp); const today = new Date(); return date.toDateString() === today.toDateString(); } function isThisWeek(timestamp) { if (!timestamp) return false; const date = new Date(timestamp); const today = new Date(); const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); return date >= weekAgo && date <= today; } function isThisMonth(timestamp) { if (!timestamp) return false; const date = new Date(timestamp); const today = new Date(); return date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); } function isThisYear(timestamp) { if (!timestamp) return false; const date = new Date(timestamp); const today = new Date(); return date.getFullYear() === today.getFullYear(); } function matchesSearch(imageUrl) { if (!searchQuery) return true; const filename = imageUrl.split('/').pop().split('?')[0]; return filename.toLowerCase().includes(searchQuery.toLowerCase()); } function shouldShowImage(imageUrl) { // First check text search if (!matchesSearch(imageUrl)) return false; // Check favorites toggle - if enabled, only show favorites if (showOnlyFavorites && !isFavorite(imageUrl)) { return false; } // Then check date filter if (currentFilter === 'all') return true; const timestamp = getFileTimestamp(imageUrl); if (!timestamp) return currentFilter === 'all'; switch (currentFilter) { case 'today': return isToday(timestamp); case 'week': return isThisWeek(timestamp); case 'month': return isThisMonth(timestamp); case 'year': return isThisYear(timestamp); default: return true; } } async function toggleFavorite(imageUrl) { const filename = imageUrl.split('/').pop().split('?')[0]; const relativePath = extractRelativePath(imageUrl); try { const response = await fetch('/toggle_gallery_favorite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename, path: relativePath, source: currentSource }) }); const data = await response.json(); if (data.favorites) { favorites = data.favorites; renderGallery(); } } catch (error) { console.error('Failed to toggle favorite', error); } } async function readMetadataFromImage(imageUrl) { try { const response = await fetch(withCacheBuster(imageUrl)); if (!response.ok) return null; const blob = await response.blob(); return await extractMetadataFromBlob(blob); } catch (error) { console.warn('Unable to read gallery metadata', error); return null; } } function renderGallery() { if (!galleryGrid) return; galleryGrid.innerHTML = ''; const filteredImages = allImages.filter(shouldShowImage); filteredImages.forEach(imageUrl => { const div = document.createElement('div'); div.className = 'gallery-item'; // Image container for positioning div.style.position = 'relative'; const img = document.createElement('img'); img.src = withCacheBuster(imageUrl); img.loading = 'lazy'; img.draggable = true; img.dataset.source = imageUrl; // Click to select div.addEventListener('click', async (e) => { // Don't select if clicking delete or favorite button if (e.target.closest('.delete-btn') || e.target.closest('.favorite-btn')) return; const metadata = await readMetadataFromImage(imageUrl); await onSelect?.({ imageUrl, metadata }); const siblings = galleryGrid.querySelectorAll('.gallery-item'); siblings.forEach(el => el.classList.remove('active')); div.classList.add('active'); }); img.addEventListener('dragstart', event => { event.dataTransfer?.setData('text/uri-list', imageUrl); event.dataTransfer?.setData('text/plain', imageUrl); if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'copy'; } }); // Toolbar for buttons const toolbar = document.createElement('div'); toolbar.className = 'gallery-item-toolbar'; // Favorite button const favoriteBtn = document.createElement('button'); favoriteBtn.className = 'favorite-btn'; if (isFavorite(imageUrl)) { favoriteBtn.classList.add('active'); } favoriteBtn.innerHTML = ` `; favoriteBtn.title = isFavorite(imageUrl) ? 'Remove from favorites' : 'Add to favorites'; favoriteBtn.addEventListener('click', async (e) => { e.stopPropagation(); await toggleFavorite(imageUrl); }); // Delete button const deleteBtn = document.createElement('button'); deleteBtn.className = 'delete-btn'; deleteBtn.innerHTML = '×'; deleteBtn.title = 'Delete image'; deleteBtn.addEventListener('click', async (e) => { e.stopPropagation(); const filename = imageUrl.split('/').pop().split('?')[0]; const relativePath = extractRelativePath(imageUrl); try { const res = await fetch('/delete_image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename, path: relativePath, source: currentSource }) }); if (res.ok) { div.remove(); // Also remove from allImages array allImages = allImages.filter(url => url !== imageUrl); } else { console.error('Failed to delete image'); } } catch (err) { console.error('Error deleting image:', err); } }); toolbar.appendChild(favoriteBtn); toolbar.appendChild(deleteBtn); div.appendChild(img); div.appendChild(toolbar); galleryGrid.appendChild(div); }); } async function load() { if (!galleryGrid) return; try { await loadFavorites(); const response = await fetch(`/gallery?source=${currentSource}&t=${new Date().getTime()}`); const data = await response.json(); allImages = data.images || []; renderGallery(); } catch (error) { console.error('Failed to load gallery:', error); } } function setFilter(filterType) { if (currentFilter === filterType) return; currentFilter = filterType; stateBySource[currentSource].filter = filterType; persistStateMap(FILTER_STORAGE_KEY, { generated: stateBySource.generated.filter, uploads: stateBySource.uploads.filter, }); renderGallery(); } function setSearch(query) { searchQuery = query || ''; stateBySource[currentSource].search = searchQuery; persistStateMap(SEARCH_STORAGE_KEY, { generated: stateBySource.generated.search, uploads: stateBySource.uploads.search, }); renderGallery(); } function toggleFavorites() { showOnlyFavorites = !showOnlyFavorites; stateBySource[currentSource].favorites = showOnlyFavorites; persistStateMap(FAVORITES_STORAGE_KEY, { generated: stateBySource.generated.favorites, uploads: stateBySource.uploads.favorites, }); renderGallery(); return showOnlyFavorites; } function setSource(source, { resetFilters = false } = {}) { const normalized = VALID_SOURCES.includes(source) ? source : 'generated'; currentSource = normalized; try { localStorage.setItem(SOURCE_STORAGE_KEY, currentSource); } catch (e) { console.warn('Failed to save history source', e); } if (resetFilters) { stateBySource[currentSource] = { ...DEFAULT_STATE }; persistStateMap(FILTER_STORAGE_KEY, { generated: stateBySource.generated.filter, uploads: stateBySource.uploads.filter, }); persistStateMap(SEARCH_STORAGE_KEY, { generated: stateBySource.generated.search, uploads: stateBySource.uploads.search, }); persistStateMap(FAVORITES_STORAGE_KEY, { generated: stateBySource.generated.favorites, uploads: stateBySource.uploads.favorites, }); } applySourceState(currentSource); return load(); } function getCurrentFilter() { return currentFilter; } function getSearchQuery() { return searchQuery; } function getCurrentSource() { return currentSource; } function isFavoritesActive() { return showOnlyFavorites; } function setFavoritesActive(active) { showOnlyFavorites = Boolean(active); stateBySource[currentSource].favorites = showOnlyFavorites; persistStateMap(FAVORITES_STORAGE_KEY, { generated: stateBySource.generated.favorites, uploads: stateBySource.uploads.favorites, }); renderGallery(); return showOnlyFavorites; } function setSearchQuery(value) { setSearch(value); } function navigate(direction) { const activeItem = galleryGrid.querySelector('.gallery-item.active'); if (!activeItem) { // If nothing active, select the first item on any arrow key const firstItem = galleryGrid.querySelector('.gallery-item'); if (firstItem) { firstItem.click(); firstItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } return; } let targetItem; if (direction === 'prev') { targetItem = activeItem.previousElementSibling; } else if (direction === 'next') { targetItem = activeItem.nextElementSibling; } if (targetItem && targetItem.classList.contains('gallery-item')) { targetItem.click(); targetItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } // Setup keyboard navigation document.addEventListener('keydown', (e) => { // Ignore if user is typing in an input if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === 'ArrowLeft') { navigate('prev'); } else if (e.key === 'ArrowRight') { navigate('next'); } }); return { load, setFilter, getCurrentFilter, setSearch, getSearchQuery, toggleFavorites, isFavoritesActive, setSource, getCurrentSource, setFavoritesActive, setSearchQuery }; }