apix/static/modules/gallery.js
2025-11-29 22:27:10 +07:00

515 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="${isFavorite(imageUrl) ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>`;
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
};
}