update code

This commit is contained in:
phamhungd 2025-11-28 15:02:21 +07:00
parent 77ed116fe7
commit ae2d5071de
7 changed files with 260 additions and 39 deletions

BIN
.DS_Store vendored

Binary file not shown.

Binary file not shown.

100
app.py
View file

@ -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():

View file

@ -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
};
}

View file

@ -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();

View file

@ -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 */

View file

@ -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"