diff --git a/.DS_Store b/.DS_Store index ea62dce..384f77c 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/__pycache__/app.cpython-311.pyc b/__pycache__/app.cpython-311.pyc new file mode 100644 index 0000000..075c115 Binary files /dev/null and b/__pycache__/app.cpython-311.pyc differ diff --git a/app.py b/app.py index 7cee721..7c44c5e 100644 --- a/app.py +++ b/app.py @@ -191,6 +191,47 @@ os.makedirs(GENERATED_DIR, exist_ok=True) # Ensure uploads directory exists UPLOADS_DIR = os.path.join(app.static_folder, 'uploads') os.makedirs(UPLOADS_DIR, exist_ok=True) +ALLOWED_GALLERY_EXTS = ('.png', '.jpg', '.jpeg', '.webp') + + +def normalize_gallery_path(path): + """Return a clean path relative to /static without traversal.""" + if not path: + return '' + cleaned = path.replace('\\', '/') + cleaned = cleaned.split('?', 1)[0] + if cleaned.startswith('/'): + cleaned = cleaned[1:] + if cleaned.startswith('static/'): + cleaned = cleaned[len('static/'):] + normalized = os.path.normpath(cleaned) + if normalized.startswith('..'): + return '' + return normalized + + +def resolve_gallery_target(source, filename=None, relative_path=None): + """Resolve the gallery source (generated/uploads) and absolute filepath.""" + cleaned_path = normalize_gallery_path(relative_path) + candidate_name = cleaned_path or (filename or '') + if not candidate_name: + return None, None, None + + normalized_name = os.path.basename(candidate_name) + + inferred_source = (source or '').lower() + if cleaned_path: + first_segment = cleaned_path.split('/')[0] + if first_segment in ('generated', 'uploads'): + inferred_source = first_segment + + if inferred_source not in ('generated', 'uploads'): + inferred_source = 'generated' + + base_dir = UPLOADS_DIR if inferred_source == 'uploads' else GENERATED_DIR + filepath = os.path.join(base_dir, normalized_name) + storage_key = f"{inferred_source}/{normalized_name}" + return inferred_source, filepath, storage_key def process_prompt_with_placeholders(prompt, note): """ @@ -544,20 +585,29 @@ def generate_image(): @app.route('/delete_image', methods=['POST']) def delete_image(): - data = request.get_json() + data = request.get_json() or {} filename = data.get('filename') - - if not filename: + source = data.get('source') + rel_path = data.get('path') or data.get('relative_path') + + resolved_source, filepath, storage_key = resolve_gallery_target(source, filename, rel_path) + if not filepath: return jsonify({'error': 'Filename is required'}), 400 - - # Security check: ensure filename is just a basename, no paths - filename = os.path.basename(filename) - filepath = os.path.join(GENERATED_DIR, filename) if os.path.exists(filepath): try: send2trash(filepath) - return jsonify({'success': True}) + + # Clean up favorites entry if it exists + favorites = load_gallery_favorites() + cleaned_favorites = [ + item for item in favorites + if item != storage_key and item != os.path.basename(filepath) + ] + if cleaned_favorites != favorites: + save_gallery_favorites(cleaned_favorites) + + return jsonify({'success': True, 'source': resolved_source}) except Exception as e: return jsonify({'error': str(e)}), 500 else: @@ -565,12 +615,19 @@ def delete_image(): @app.route('/gallery') def get_gallery(): - # List all png files in generated dir, sorted by modification time (newest first) - files = glob.glob(os.path.join(GENERATED_DIR, '*.png')) + # List all images in the chosen source directory, sorted by modification time (newest first) + source_param = (request.args.get('source') or 'generated').lower() + base_dir = UPLOADS_DIR if source_param == 'uploads' else GENERATED_DIR + resolved_source = 'uploads' if base_dir == UPLOADS_DIR else 'generated' + + files = [ + f for f in glob.glob(os.path.join(base_dir, '*')) + if os.path.splitext(f)[1].lower() in ALLOWED_GALLERY_EXTS + ] files.sort(key=os.path.getmtime, reverse=True) - image_urls = [url_for('static', filename=f'generated/{os.path.basename(f)}') for f in files] - response = jsonify({'images': image_urls}) + image_urls = [url_for('static', filename=f'{resolved_source}/{os.path.basename(f)}') for f in files] + response = jsonify({'images': image_urls, 'source': resolved_source}) response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return response @@ -652,22 +709,25 @@ def get_gallery_favorites(): def toggle_gallery_favorite(): data = request.get_json() or {} filename = data.get('filename') + source = data.get('source') + rel_path = data.get('path') or data.get('relative_path') - if not filename: + resolved_source, _, storage_key = resolve_gallery_target(source, filename, rel_path) + if not storage_key: return jsonify({'error': 'Filename is required'}), 400 - # Security: ensure filename is just a basename - filename = os.path.basename(filename) - favorites = load_gallery_favorites() + legacy_key = os.path.basename(storage_key) - if filename in favorites: - favorites = [item for item in favorites if item != filename] + if storage_key in favorites or legacy_key in favorites: + favorites = [item for item in favorites if item not in (storage_key, legacy_key)] + is_favorite = False else: - favorites.append(filename) + favorites.append(storage_key) + is_favorite = True save_gallery_favorites(favorites) - return jsonify({'favorites': favorites, 'is_favorite': filename in favorites}) + return jsonify({'favorites': favorites, 'is_favorite': is_favorite, 'source': resolved_source}) @app.route('/save_template', methods=['POST']) def save_template(): diff --git a/static/modules/gallery.js b/static/modules/gallery.js index 6f6acb4..b99e730 100644 --- a/static/modules/gallery.js +++ b/static/modules/gallery.js @@ -3,10 +3,13 @@ import { extractMetadataFromBlob } from './metadata.js'; const FILTER_STORAGE_KEY = 'gemini-app-history-filter'; const SEARCH_STORAGE_KEY = 'gemini-app-history-search'; +const SOURCE_STORAGE_KEY = 'gemini-app-history-source'; +const VALID_SOURCES = ['generated', 'uploads']; export function createGallery({ galleryGrid, onSelect }) { let currentFilter = 'all'; let searchQuery = ''; + let currentSource = 'generated'; let allImages = []; let favorites = []; let showOnlyFavorites = false; // New toggle state @@ -18,6 +21,11 @@ export function createGallery({ galleryGrid, onSelect }) { const savedSearch = localStorage.getItem(SEARCH_STORAGE_KEY); if (savedSearch) searchQuery = savedSearch; + + const savedSource = localStorage.getItem(SOURCE_STORAGE_KEY); + if (savedSource && VALID_SOURCES.includes(savedSource)) { + currentSource = savedSource; + } } catch (e) { console.warn('Failed to load history filter/search', e); } @@ -33,9 +41,34 @@ export function createGallery({ galleryGrid, onSelect }) { } } - function isFavorite(imageUrl) { + 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 favorites.includes(filename); + 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 @@ -121,12 +154,17 @@ export function createGallery({ galleryGrid, onSelect }) { 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 }) + body: JSON.stringify({ + filename, + path: relativePath, + source: currentSource + }) }); const data = await response.json(); @@ -219,11 +257,16 @@ export function createGallery({ galleryGrid, onSelect }) { 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 }) + body: JSON.stringify({ + filename, + path: relativePath, + source: currentSource + }) }); if (res.ok) { @@ -251,7 +294,7 @@ export function createGallery({ galleryGrid, onSelect }) { if (!galleryGrid) return; try { await loadFavorites(); - const response = await fetch(`/gallery?t=${new Date().getTime()}`); + const response = await fetch(`/gallery?source=${currentSource}&t=${new Date().getTime()}`); const data = await response.json(); allImages = data.images || []; renderGallery(); @@ -293,6 +336,28 @@ export function createGallery({ galleryGrid, onSelect }) { 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) { + currentFilter = 'all'; + showOnlyFavorites = false; + searchQuery = ''; + try { + localStorage.setItem(FILTER_STORAGE_KEY, currentFilter); + localStorage.setItem(SEARCH_STORAGE_KEY, searchQuery); + } catch (e) { + console.warn('Failed to reset history filters', e); + } + } + return load(); + } + function getCurrentFilter() { return currentFilter; } @@ -301,10 +366,24 @@ export function createGallery({ galleryGrid, onSelect }) { return searchQuery; } + function getCurrentSource() { + return currentSource; + } + function isFavoritesActive() { return showOnlyFavorites; } + function setFavoritesActive(active) { + showOnlyFavorites = Boolean(active); + renderGallery(); + return showOnlyFavorites; + } + + function setSearchQuery(value) { + setSearch(value); + } + function navigate(direction) { const activeItem = galleryGrid.querySelector('.gallery-item.active'); @@ -343,5 +422,17 @@ export function createGallery({ galleryGrid, onSelect }) { } }); - return { load, setFilter, getCurrentFilter, setSearch, getSearchQuery, toggleFavorites, isFavoritesActive }; + return { + load, + setFilter, + getCurrentFilter, + setSearch, + getSearchQuery, + toggleFavorites, + isFavoritesActive, + setSource, + getCurrentSource, + setFavoritesActive, + setSearchQuery + }; } diff --git a/static/script.js b/static/script.js index c61fb20..c3eb5df 100644 --- a/static/script.js +++ b/static/script.js @@ -1588,6 +1588,48 @@ document.addEventListener('DOMContentLoaded', () => { // Setup history filter buttons const historyFilterBtns = document.querySelectorAll('.history-filter-btn'); const historyFavoritesBtn = document.querySelector('.history-favorites-btn'); + const historySourceBtns = document.querySelectorAll('.history-source-btn'); + const initialSource = gallery.getCurrentSource ? gallery.getCurrentSource() : 'generated'; + + historySourceBtns.forEach(btn => { + const isActive = btn.dataset.source === initialSource; + btn.classList.toggle('active', isActive); + btn.setAttribute('aria-pressed', String(isActive)); + + btn.addEventListener('click', async () => { + const targetSource = btn.dataset.source || 'generated'; + historySourceBtns.forEach(b => { + const active = b === btn; + b.classList.toggle('active', active); + b.setAttribute('aria-pressed', String(active)); + }); + await gallery.setSource(targetSource, { resetFilters: true }); + + // Reset filters UI to show all when switching source + historyFilterBtns.forEach(b => { + if (!b.classList.contains('history-favorites-btn')) { + b.classList.toggle('active', b.dataset.filter === 'all'); + } + }); + + // Disable favorites toggle on source change + if (historyFavoritesBtn) { + historyFavoritesBtn.classList.remove('active'); + } + if (gallery.setFavoritesActive) { + gallery.setFavoritesActive(false); + } + + // Clear search box + const historySearchInputEl = document.getElementById('history-search-input'); + if (historySearchInputEl) { + historySearchInputEl.value = ''; + } + if (gallery.setSearchQuery) { + gallery.setSearchQuery(''); + } + }); + }); // Set initial active state based on saved filter const currentFilter = gallery.getCurrentFilter(); diff --git a/static/style.css b/static/style.css index f0d56eb..119aa25 100644 --- a/static/style.css +++ b/static/style.css @@ -1051,16 +1051,6 @@ button#generate-btn:disabled { background: var(--panel-backdrop); } -.history-section h3 { - font-size: 0.875rem; - color: var(--text-secondary); - margin-bottom: 0.5rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - margin: 0; -} - .history-header { display: flex; justify-content: space-between; @@ -1069,6 +1059,39 @@ button#generate-btn:disabled { gap: 0.75rem; } +.history-source-toggle { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.history-source-btn { + padding: 0.4rem 0.9rem; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + background: rgba(255, 255, 255, 0.05); + color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.history-source-btn:hover { + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.08); +} + +.history-source-btn.active { + background: linear-gradient(135deg, var(--accent-color), var(--accent-hover)); + color: #111; + box-shadow: 0 4px 14px rgba(251, 191, 36, 0.35); + border-color: transparent; +} + .history-filter-group { display: flex; gap: 0.15rem; @@ -1149,7 +1172,7 @@ button#generate-btn:disabled { transition: all 0.2s; flex-shrink: 0; background: rgba(255, 255, 255, 0.02); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5); + box-shadow: 0 0px 0px rgba(0, 0, 0, 0.5); } .gallery-item:hover { @@ -1158,7 +1181,7 @@ button#generate-btn:disabled { .gallery-item.active { border-color: var(--accent-color); - box-shadow: 0 0 25px rgba(251, 191, 36, 0.4); + box-shadow: 0 0 10px rgba(251, 191, 36, 0.4); } /* New styles start here */ diff --git a/templates/index.html b/templates/index.html index 19808d8..94f714c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -241,7 +241,12 @@
-

History

+
+ + +