update code
This commit is contained in:
parent
77ed116fe7
commit
ae2d5071de
7 changed files with 260 additions and 39 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
BIN
__pycache__/app.cpython-311.pyc
Normal file
BIN
__pycache__/app.cpython-311.pyc
Normal file
Binary file not shown.
98
app.py
98
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')
|
||||
source = data.get('source')
|
||||
rel_path = data.get('path') or data.get('relative_path')
|
||||
|
||||
if not filename:
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -241,7 +241,12 @@
|
|||
</main>
|
||||
<section class="history-section">
|
||||
<div class="history-header">
|
||||
<h3>History</h3>
|
||||
<div class="history-source-toggle" role="group" aria-label="Chọn nguồn lịch sử">
|
||||
<button type="button" class="history-source-btn active" data-source="generated"
|
||||
aria-pressed="true">History</button>
|
||||
<button type="button" class="history-source-btn" data-source="uploads"
|
||||
aria-pressed="false">Upload</button>
|
||||
</div>
|
||||
<div class="history-filter-group">
|
||||
<input type="text" id="history-search-input" class="history-search-input" placeholder="Day...">
|
||||
<button type="button" class="history-filter-btn history-favorites-btn" data-filter="favorites"
|
||||
|
|
|
|||
Loading…
Reference in a new issue