update ver

This commit is contained in:
phamhungd 2025-11-22 00:23:18 +07:00
parent c456468b31
commit 0b24c74ef3
10 changed files with 949 additions and 484 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
.DS_Store .DS_Store
.DS_Store .DS_Store
/.venv /.venv
/static/uploads

151
app.py
View file

@ -2,10 +2,12 @@ import os
import base64 import base64
import uuid import uuid
import glob import glob
import json
from io import BytesIO
from flask import Flask, render_template, request, jsonify, url_for from flask import Flask, render_template, request, jsonify, url_for
from google import genai from google import genai
from google.genai import types from google.genai import types
from PIL import Image from PIL import Image, PngImagePlugin
app = Flask(__name__) app = Flask(__name__)
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
@ -14,6 +16,10 @@ app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
GENERATED_DIR = os.path.join(app.static_folder, 'generated') GENERATED_DIR = os.path.join(app.static_folder, 'generated')
os.makedirs(GENERATED_DIR, exist_ok=True) 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)
@app.route('/') @app.route('/')
def index(): def index():
return render_template('index.html') return render_template('index.html')
@ -29,6 +35,7 @@ def generate_image():
resolution = form.get('resolution', '2K') resolution = form.get('resolution', '2K')
api_key = form.get('api_key') or os.environ.get('GOOGLE_API_KEY') api_key = form.get('api_key') or os.environ.get('GOOGLE_API_KEY')
reference_files = request.files.getlist('reference_images') reference_files = request.files.getlist('reference_images')
reference_paths_json = form.get('reference_image_paths')
else: else:
data = request.get_json() or {} data = request.get_json() or {}
prompt = data.get('prompt') prompt = data.get('prompt')
@ -36,6 +43,7 @@ def generate_image():
resolution = data.get('resolution', '2K') resolution = data.get('resolution', '2K')
api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY') api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY')
reference_files = [] reference_files = []
reference_paths_json = data.get('reference_image_paths')
if not prompt: if not prompt:
return jsonify({'error': 'Prompt is required'}), 400 return jsonify({'error': 'Prompt is required'}), 400
@ -53,14 +61,89 @@ def generate_image():
if aspect_ratio and aspect_ratio != 'Auto': if aspect_ratio and aspect_ratio != 'Auto':
image_config_args["aspect_ratio"] = aspect_ratio image_config_args["aspect_ratio"] = aspect_ratio
# Process reference paths and files
final_reference_paths = []
contents = [prompt] contents = [prompt]
for reference in reference_files:
# Parse reference paths from frontend
frontend_paths = []
if reference_paths_json:
try: try:
reference.stream.seek(0) frontend_paths = json.loads(reference_paths_json)
reference_image = Image.open(reference.stream) except json.JSONDecodeError:
contents.append(reference_image) pass
except Exception:
continue # If no paths provided but we have files (legacy or simple upload), treat all as new uploads
# But we need to handle the mix.
# Strategy: Iterate frontend_paths. If it looks like a path/URL, keep it.
# If it doesn't (or is null), consume from reference_files.
file_index = 0
# If frontend_paths is empty but we have files, just use the files
if not frontend_paths and reference_files:
for _ in reference_files:
frontend_paths.append(None) # Placeholder for each file
for path in frontend_paths:
if path and (path.startswith('/') or path.startswith('http')):
# Existing path/URL
final_reference_paths.append(path)
# We also need to add the image content to the prompt
# We need to fetch it or read it if it's local (server-side local)
# If it's a URL we generated, it's in static/generated or static/uploads
# path might be "http://localhost:8888/static/generated/..." or "/static/generated/..."
# Extract relative path to open file
# Assuming path contains '/static/'
try:
if '/static/' in path:
rel_path = path.split('/static/')[1]
abs_path = os.path.join(app.static_folder, rel_path)
if os.path.exists(abs_path):
img = Image.open(abs_path)
contents.append(img)
else:
print(f"Warning: Reference file not found at {abs_path}")
else:
print(f"Warning: Could not resolve local path for {path}")
except Exception as e:
print(f"Error loading reference from path {path}: {e}")
elif file_index < len(reference_files):
# New upload
file = reference_files[file_index]
file_index += 1
try:
# Save to uploads
ext = os.path.splitext(file.filename)[1]
if not ext:
ext = '.png'
filename = f"{uuid.uuid4()}{ext}"
filepath = os.path.join(UPLOADS_DIR, filename)
# We need to read the file for Gemini AND save it
# file.stream is a stream.
file.stream.seek(0)
file_bytes = file.read()
with open(filepath, 'wb') as f:
f.write(file_bytes)
# Add to contents
image = Image.open(BytesIO(file_bytes))
contents.append(image)
# Add to final paths
# URL for the uploaded file
rel_path = os.path.join('uploads', filename)
file_url = url_for('static', filename=rel_path)
final_reference_paths.append(file_url)
except Exception as e:
print(f"Error processing uploaded file: {e}")
continue
response = client.models.generate_content( response = client.models.generate_content(
model="gemini-3-pro-image-preview", model="gemini-3-pro-image-preview",
@ -74,18 +157,37 @@ def generate_image():
for part in response.parts: for part in response.parts:
if part.inline_data: if part.inline_data:
image_bytes = part.inline_data.data image_bytes = part.inline_data.data
# Save image to file image = Image.open(BytesIO(image_bytes))
png_info = PngImagePlugin.PngInfo()
filename = f"{uuid.uuid4()}.png" filename = f"{uuid.uuid4()}.png"
filepath = os.path.join(GENERATED_DIR, filename) filepath = os.path.join(GENERATED_DIR, filename)
with open(filepath, "wb") as f: rel_path = os.path.join('generated', filename)
f.write(image_bytes) image_url = url_for('static', filename=rel_path)
image_url = url_for('static', filename=f'generated/{filename}') metadata = {
image_data = base64.b64encode(image_bytes).decode('utf-8') 'prompt': prompt,
'aspect_ratio': aspect_ratio or 'Auto',
'resolution': resolution,
'reference_images': final_reference_paths,
}
png_info.add_text('sdvn_meta', json.dumps(metadata))
buffer = BytesIO()
image.save(buffer, format='PNG', pnginfo=png_info)
final_bytes = buffer.getvalue()
# Save image to file
with open(filepath, 'wb') as f:
f.write(final_bytes)
image_data = base64.b64encode(final_bytes).decode('utf-8')
return jsonify({ return jsonify({
'image': image_url, 'image': image_url,
'image_data': image_data, 'image_data': image_data,
'metadata': metadata,
}) })
return jsonify({'error': 'No image generated'}), 500 return jsonify({'error': 'No image generated'}), 500
@ -93,6 +195,27 @@ def generate_image():
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/delete_image', methods=['POST'])
def delete_image():
data = request.get_json()
filename = data.get('filename')
if not filename:
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:
os.remove(filepath)
return jsonify({'success': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
else:
return jsonify({'error': 'File not found'}), 404
@app.route('/gallery') @app.route('/gallery')
def get_gallery(): def get_gallery():
# List all png files in generated dir, sorted by modification time (newest first) # List all png files in generated dir, sorted by modification time (newest first)

94
static/modules/gallery.js Normal file
View file

@ -0,0 +1,94 @@
import { withCacheBuster } from './utils.js';
import { extractMetadataFromBlob } from './metadata.js';
export function createGallery({ galleryGrid, onSelect }) {
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;
}
}
async function load() {
if (!galleryGrid) return;
try {
const response = await fetch(`/gallery?t=${new Date().getTime()}`);
const data = await response.json();
galleryGrid.innerHTML = '';
data.images.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 button
if (e.target.closest('.delete-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';
}
});
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.innerHTML = '×';
deleteBtn.title = 'Delete image';
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm('Are you sure you want to delete this image?')) return;
const filename = imageUrl.split('/').pop().split('?')[0];
try {
const res = await fetch('/delete_image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
if (res.ok) {
div.remove();
} else {
console.error('Failed to delete image');
}
} catch (err) {
console.error('Error deleting image:', err);
}
});
div.appendChild(img);
div.appendChild(deleteBtn);
galleryGrid.appendChild(div);
});
} catch (error) {
console.error('Failed to load gallery:', error);
}
}
return { load };
}

View file

@ -0,0 +1,46 @@
export async function extractMetadataFromBlob(blob, key = 'sdvn_meta') {
try {
const arrayBuffer = await blob.arrayBuffer();
if (arrayBuffer.byteLength < 8) return null;
const view = new DataView(arrayBuffer);
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
for (let i = 0; i < signature.length; i++) {
if (view.getUint8(i) !== signature[i]) return null;
}
let offset = 8;
const decoder = new TextDecoder('utf-8');
while (offset + 12 <= view.byteLength) {
const length = view.getUint32(offset);
offset += 4;
const chunkTypeBytes = new Uint8Array(arrayBuffer, offset, 4);
const chunkType = decoder.decode(chunkTypeBytes);
offset += 4;
const chunkData = new Uint8Array(arrayBuffer, offset, length);
offset += length;
offset += 4; // skip CRC
if (chunkType === 'tEXt') {
const chunkText = decoder.decode(chunkData);
const nullIndex = chunkText.indexOf('\u0000');
if (nullIndex !== -1) {
const chunkKey = chunkText.slice(0, nullIndex);
const chunkValue = chunkText.slice(nullIndex + 1);
if (chunkKey === key) {
try {
return JSON.parse(chunkValue);
} catch (error) {
console.warn('Invalid metadata JSON', error);
return null;
}
}
}
}
}
} catch (error) {
console.warn('Unable to extract metadata', error);
}
return null;
}

57
static/modules/popup.js Normal file
View file

@ -0,0 +1,57 @@
export function setupHelpPopups({ buttonsSelector, overlayId, titleId, bodyId, closeBtnId, content }) {
const overlay = document.getElementById(overlayId);
const titleEl = document.getElementById(titleId);
const bodyEl = document.getElementById(bodyId);
const closeBtn = document.getElementById(closeBtnId);
const buttons = document.querySelectorAll(buttonsSelector);
if (!overlay || !titleEl || !bodyEl) return;
buttons.forEach(button => {
button.addEventListener('click', () => {
const target = button.dataset.popupTarget;
if (target) {
showPopup(target);
}
});
});
closeBtn?.addEventListener('click', closePopup);
overlay.addEventListener('click', event => {
if (event.target === overlay) {
closePopup();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !overlay.classList.contains('hidden')) {
event.preventDefault();
closePopup();
}
});
function showPopup(type) {
const popupContent = content[type];
if (!popupContent) return;
titleEl.textContent = popupContent.title;
bodyEl.innerHTML = popupContent.sections
.map(section => {
const items = (section.items || []).map(item => `<li>${item}</li>`).join('');
return `
<section class="popup-section">
<h3>${section.heading}</h3>
<ul>${items}</ul>
</section>
`;
})
.join('');
overlay.classList.remove('hidden');
}
function closePopup() {
overlay.classList.add('hidden');
}
}

View file

@ -0,0 +1,313 @@
import { dataUrlToBlob, withCacheBuster } from './utils.js';
export function createReferenceSlotManager(imageInputGrid, options = {}) {
const MAX_IMAGE_SLOTS = 16;
const INITIAL_IMAGE_SLOTS = 4;
const onChange = options.onChange;
const imageSlotState = [];
let cachedReferenceImages = [];
function initialize(initialCached = []) {
cachedReferenceImages = Array.isArray(initialCached) ? initialCached : [];
const requiredSlots = Math.min(
MAX_IMAGE_SLOTS,
Math.max(INITIAL_IMAGE_SLOTS, cachedReferenceImages.length + 1)
);
for (let i = 0; i < requiredSlots; i++) {
addImageSlot();
}
cachedReferenceImages.forEach((cached, index) => applyCachedImageToSlot(index, cached));
maybeAddSlot();
}
function getReferenceFiles() {
return imageSlotState
.filter(record => record.data && !record.data.sourceUrl) // Only return files if no sourceUrl
.map(record => getSlotFile(record))
.filter(Boolean);
}
function getReferencePaths() {
return imageSlotState.map(record => {
if (!record.data) return null;
return record.data.sourceUrl || record.data.file?.name || null;
});
}
function serializeReferenceImages() {
return imageSlotState
.map((record, index) => {
if (!record.data || !record.data.preview) return null;
const name = record.data.cached?.name || record.data.file?.name || `reference-${index + 1}.png`;
const type = record.data.cached?.type || record.data.file?.type || 'image/png';
return {
name,
type,
dataUrl: record.data.preview,
sourceUrl: record.data.sourceUrl,
};
})
.filter(Boolean);
}
function addImageSlot() {
if (!imageInputGrid || imageSlotState.length >= MAX_IMAGE_SLOTS) return;
const index = imageSlotState.length;
const slotElement = createImageSlotElement(index);
imageSlotState.push({ slot: slotElement, data: null });
imageInputGrid.appendChild(slotElement);
}
function createImageSlotElement(index) {
const slot = document.createElement('div');
slot.className = 'image-slot empty';
slot.dataset.index = index;
const placeholder = document.createElement('div');
placeholder.className = 'slot-placeholder';
placeholder.innerHTML = '<span class="slot-icon">+</span>';
slot.appendChild(placeholder);
const preview = document.createElement('img');
preview.className = 'slot-preview hidden';
preview.alt = 'Uploaded reference';
slot.appendChild(preview);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'slot-remove hidden';
removeBtn.setAttribute('aria-label', 'Remove image');
removeBtn.textContent = '×';
slot.appendChild(removeBtn);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.className = 'slot-input';
slot.appendChild(input);
slot.addEventListener('click', event => {
if (event.target === removeBtn) return;
input.click();
});
input.addEventListener('change', () => {
if (input.files && input.files.length) {
handleSlotFile(index, input.files[0]);
}
});
slot.addEventListener('dragenter', event => {
event.preventDefault();
slot.classList.add('drag-over');
});
slot.addEventListener('dragover', event => {
event.preventDefault();
slot.classList.add('drag-over');
});
slot.addEventListener('dragleave', () => {
slot.classList.remove('drag-over');
});
slot.addEventListener('drop', async event => {
event.preventDefault();
slot.classList.remove('drag-over');
const file = event.dataTransfer?.files?.[0];
if (file) {
handleSlotFile(index, file);
return;
}
const imageUrl = event.dataTransfer?.getData('text/uri-list')
|| event.dataTransfer?.getData('text/plain');
if (imageUrl) {
await handleSlotDropFromHistory(index, imageUrl);
}
});
removeBtn.addEventListener('click', event => {
event.stopPropagation();
clearSlot(index);
});
return slot;
}
function handleSlotFile(index, file, sourceUrl = null) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const previewUrl = reader.result;
if (typeof previewUrl !== 'string') return;
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = {
file,
preview: previewUrl,
cached: null,
sourceUrl: sourceUrl,
};
updateSlotVisual(index);
onChange?.();
maybeAddSlot();
};
reader.readAsDataURL(file);
}
async function handleSlotDropFromHistory(index, imageUrl) {
try {
const response = await fetch(withCacheBuster(imageUrl));
if (!response.ok) {
console.warn('Failed to fetch history image', response.statusText);
return;
}
const blob = await response.blob();
const name = imageUrl.split('/').pop()?.split('?')[0] || `history-${index + 1}.png`;
const type = blob.type || 'image/png';
const file = new File([blob], name, { type });
// Extract relative path if possible, or use full URL
// Assuming imageUrl is like http://localhost:8888/static/generated/uuid.png
// We want /static/generated/uuid.png or just generated/uuid.png if that's how we store it.
// The app.py stores 'generated/filename.png' in metadata if we send it.
// But wait, app.py constructs image_url using url_for('static', filename=rel_path).
// Let's just store the full URL or relative path.
// If we store the full URL, we can fetch it back.
let sourceUrl = imageUrl;
try {
const urlObj = new URL(imageUrl, window.location.origin);
if (urlObj.origin === window.location.origin) {
sourceUrl = urlObj.pathname;
}
} catch (e) {
// ignore
}
handleSlotFile(index, file, sourceUrl);
} catch (error) {
console.error('Unable to import history image', error);
}
}
function updateSlotVisual(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
const slot = slotRecord.slot;
const placeholder = slot.querySelector('.slot-placeholder');
const preview = slot.querySelector('.slot-preview');
const removeBtn = slot.querySelector('.slot-remove');
if (slotRecord.data && slotRecord.data.preview) {
preview.src = slotRecord.data.preview;
preview.classList.remove('hidden');
placeholder.classList.add('hidden');
removeBtn.classList.remove('hidden');
slot.classList.add('filled');
slot.classList.remove('empty');
} else {
preview.src = '';
preview.classList.add('hidden');
placeholder.classList.remove('hidden');
removeBtn.classList.add('hidden');
slot.classList.add('empty');
slot.classList.remove('filled');
}
}
function clearSlot(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = null;
const input = slotRecord.slot.querySelector('.slot-input');
if (input) input.value = '';
updateSlotVisual(index);
onChange?.();
}
function maybeAddSlot() {
const hasEmpty = imageSlotState.some(record => !record.data);
if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) {
addImageSlot();
}
}
function applyCachedImageToSlot(index, cached) {
if (!cached || !cached.dataUrl) return;
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = {
file: null,
preview: cached.dataUrl,
cached: {
name: cached.name,
type: cached.type,
dataUrl: cached.dataUrl,
},
sourceUrl: cached.sourceUrl || null,
};
updateSlotVisual(index);
}
async function setReferenceImages(paths) {
if (!Array.isArray(paths)) return;
// Clear existing slots first? Or overwrite?
// Let's overwrite from the beginning.
// Ensure we have enough slots
while (imageSlotState.length < paths.length && imageSlotState.length < MAX_IMAGE_SLOTS) {
addImageSlot();
}
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
if (!path) {
clearSlot(i);
continue;
}
// Check if it looks like a path we can fetch
if (path.startsWith('/') || path.startsWith('http')) {
await handleSlotDropFromHistory(i, path);
} else {
// It's likely just a filename of a local file we can't restore
// So we clear the slot
clearSlot(i);
}
}
// Clear remaining slots
for (let i = paths.length; i < imageSlotState.length; i++) {
clearSlot(i);
}
maybeAddSlot();
}
function getSlotFile(record) {
if (!record.data) return null;
if (record.data.file) return record.data.file;
if (record.data.cached && record.data.cached.dataUrl) {
const blob = dataUrlToBlob(record.data.cached.dataUrl);
if (!blob) return null;
const fileName = record.data.cached.name || `reference.png`;
const fileType = record.data.cached.type || 'image/png';
return new File([blob], fileName, { type: fileType });
}
return null;
}
return {
initialize,
getReferenceFiles,
getReferencePaths,
serializeReferenceImages,
setReferenceImages,
};
}

26
static/modules/utils.js Normal file
View file

@ -0,0 +1,26 @@
export function withCacheBuster(url) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}t=${new Date().getTime()}`;
}
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
export function dataUrlToBlob(dataUrl) {
try {
const [prefix, base64] = dataUrl.split(',');
const mimeMatch = prefix.match(/:(.*?);/);
const mime = mimeMatch ? mimeMatch[1] : 'image/png';
const binary = atob(base64);
const len = binary.length;
const buffer = new Uint8Array(len);
for (let i = 0; i < len; i++) {
buffer[i] = binary.charCodeAt(i);
}
return new Blob([buffer], { type: mime });
} catch (error) {
console.warn('Unable to convert cached image to blob', error);
return null;
}
}

View file

@ -1,87 +1,106 @@
import { withCacheBuster, clamp } from './modules/utils.js';
import { createGallery } from './modules/gallery.js';
import { createReferenceSlotManager } from './modules/referenceSlots.js';
import { setupHelpPopups } from './modules/popup.js';
import { extractMetadataFromBlob } from './modules/metadata.js';
const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings';
const ZOOM_STEP = 0.1;
const MIN_ZOOM = 0.4;
const MAX_ZOOM = 4;
const infoContent = {
title: 'Thông tin',
sections: [
{
heading: 'Liên hệ',
items: [
'Người tạo: Phạm Hưng',
'Group: <a href="https://www.facebook.com/groups/stablediffusion.vn/" target="_blank" rel="noreferrer">SDVN - Cộng đồng AI Art</a>',
'Website: <a href="https://sdvn.vn" target="_blank" rel="noreferrer">sdvn.vn</a>',
],
},
],
};
const docsContent = {
title: 'Phím tắt và mẹo',
sections: [
{
heading: 'Phím tắt',
items: [
'Ctrl/Cmd + Enter → tạo ảnh mới',
'D → tải ảnh hiện tại',
'D → tải ảnh hiện tại',
'Space → reset zoom/pan vùng hiển thị ảnh',
'Esc → đóng popup thông tin/docs',
],
},
{
heading: 'Thao tác nhanh',
items: [
'Kéo ảnh từ lịch sử vào ô tham chiếu để tái sử dụng',
'Tùy chỉnh tỉ lệ và độ phân giải trước khi nhấn Generate',
'API key và prompt được lưu để lần sau không phải nhập lại',
],
},
],
};
const POPUP_CONTENT = {
info: infoContent,
docs: docsContent,
};
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const generateBtn = document.getElementById('generate-btn'); const generateBtn = document.getElementById('generate-btn');
const promptInput = document.getElementById('prompt'); const promptInput = document.getElementById('prompt');
const aspectRatioInput = document.getElementById('aspect-ratio'); const aspectRatioInput = document.getElementById('aspect-ratio');
const resolutionInput = document.getElementById('resolution'); const resolutionInput = document.getElementById('resolution');
const apiKeyInput = document.getElementById('api-key'); const apiKeyInput = document.getElementById('api-key');
// States
const placeholderState = document.getElementById('placeholder-state'); const placeholderState = document.getElementById('placeholder-state');
const loadingState = document.getElementById('loading-state'); const loadingState = document.getElementById('loading-state');
const errorState = document.getElementById('error-state'); const errorState = document.getElementById('error-state');
const resultState = document.getElementById('result-state'); const resultState = document.getElementById('result-state');
const errorText = document.getElementById('error-text'); const errorText = document.getElementById('error-text');
const generatedImage = document.getElementById('generated-image'); const generatedImage = document.getElementById('generated-image');
const downloadLink = document.getElementById('download-link'); const downloadLink = document.getElementById('download-link');
const galleryGrid = document.getElementById('gallery-grid'); const galleryGrid = document.getElementById('gallery-grid');
const imageInputGrid = document.getElementById('image-input-grid'); const imageInputGrid = document.getElementById('image-input-grid');
const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings';
const MAX_IMAGE_SLOTS = 16;
const INITIAL_IMAGE_SLOTS = 4;
const imageSlotState = [];
let cachedReferenceImages = [];
const imageDisplayArea = document.querySelector('.image-display-area'); const imageDisplayArea = document.querySelector('.image-display-area');
const canvasToolbar = document.querySelector('.canvas-toolbar'); const canvasToolbar = document.querySelector('.canvas-toolbar');
const popupOverlay = document.getElementById('popup-overlay');
const popupTitleEl = document.getElementById('popup-title');
const popupBodyEl = document.getElementById('popup-body');
const popupCloseBtn = document.getElementById('popup-close');
const popupButtons = document.querySelectorAll('[data-popup-target]');
const ZOOM_STEP = 0.1;
const MIN_ZOOM = 0.4;
const MAX_ZOOM = 4;
let zoomLevel = 1; let zoomLevel = 1;
let panOffset = { x: 0, y: 0 }; let panOffset = { x: 0, y: 0 };
let isPanning = false; let isPanning = false;
let lastPointer = { x: 0, y: 0 }; let lastPointer = { x: 0, y: 0 };
const infoContent = { const slotManager = createReferenceSlotManager(imageInputGrid, {
title: 'Thông tin', onChange: persistSettings,
sections: [ });
{
heading: 'Liên hệ',
items: [
'Người tạo: Phạm Hưng',
'Group: <a href="https://www.facebook.com/groups/stablediffusion.vn/" target="_blank" rel="noreferrer">SDVN - Cộng đồng AI Art</a>',
'Website: <a href="https://sdvn.vn" target="_blank" rel="noreferrer">sdvn.vn</a>',
],
},
],
};
const docsContent = { const gallery = createGallery({
title: 'Phím tắt và mẹo', galleryGrid,
sections: [ onSelect: async ({ imageUrl, metadata }) => {
{ displayImage(imageUrl);
heading: 'Phím tắt', if (metadata) {
items: [ applyMetadata(metadata);
'Ctrl/Cmd + Enter → tạo ảnh mới', }
'D → tải ảnh hiện tại', },
'R → reset zoom/pan vùng hiển thị ảnh', });
'Esc → đóng popup thông tin/docs',
],
},
{
heading: 'Thao tác nhanh',
items: [
'Kéo ảnh từ lịch sử vào ô tham chiếu để tái sử dụng',
'Tùy chỉnh tỉ lệ và độ phân giải trước khi nhấn Generate',
'API key và prompt được lưu để lần sau không phải nhập lại',
],
},
],
};
const POPUP_CONTENT = { setupHelpPopups({
info: infoContent, buttonsSelector: '[data-popup-target]',
docs: docsContent, overlayId: 'popup-overlay',
}; titleId: 'popup-title',
bodyId: 'popup-body',
closeBtnId: 'popup-close',
content: POPUP_CONTENT,
});
// Load gallery on start const savedSettings = loadSettings();
loadSettings(); slotManager.initialize(savedSettings.referenceImages || []);
initializeImageInputs();
loadGallery();
apiKeyInput.addEventListener('input', persistSettings); apiKeyInput.addEventListener('input', persistSettings);
promptInput.addEventListener('input', persistSettings); promptInput.addEventListener('input', persistSettings);
@ -99,7 +118,6 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// Set UI to loading
setViewState('loading'); setViewState('loading');
generateBtn.disabled = true; generateBtn.disabled = true;
@ -124,12 +142,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (data.image) { if (data.image) {
displayImage(data.image, data.image_data); displayImage(data.image, data.image_data);
// Refresh gallery to show new image gallery.load();
loadGallery();
} else { } else {
throw new Error('No image data received'); throw new Error('No image data received');
} }
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
} finally { } finally {
@ -137,6 +153,78 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
document.addEventListener('keydown', handleGenerateShortcut);
document.addEventListener('keydown', handleResetShortcut);
document.addEventListener('keydown', handleDownloadShortcut);
if (imageDisplayArea) {
imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false });
imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown);
// Drag and drop support
imageDisplayArea.addEventListener('dragover', (e) => {
e.preventDefault();
imageDisplayArea.classList.add('drag-over');
});
imageDisplayArea.addEventListener('dragleave', (e) => {
e.preventDefault();
imageDisplayArea.classList.remove('drag-over');
});
imageDisplayArea.addEventListener('drop', async (e) => {
e.preventDefault();
imageDisplayArea.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
try {
// Display image immediately
const objectUrl = URL.createObjectURL(file);
displayImage(objectUrl);
// Extract and apply metadata
const metadata = await extractMetadataFromBlob(file);
if (metadata) {
applyMetadata(metadata);
}
} catch (error) {
console.error('Error handling dropped image:', error);
}
}
}
else {
const imageUrl = e.dataTransfer?.getData('text/uri-list')
|| e.dataTransfer?.getData('text/plain');
if (imageUrl) {
await handleCanvasDropUrl(imageUrl.trim());
}
}
});
}
if (canvasToolbar) {
canvasToolbar.addEventListener('click', handleCanvasToolbarClick);
}
document.addEventListener('pointermove', handleCanvasPointerMove);
document.addEventListener('pointerup', () => {
if (isPanning && imageDisplayArea) {
imageDisplayArea.style.cursor = 'grab';
}
isPanning = false;
});
document.addEventListener('pointerleave', () => {
if (isPanning && imageDisplayArea) {
imageDisplayArea.style.cursor = 'grab';
}
isPanning = false;
});
loadGallery();
function setViewState(state) { function setViewState(state) {
placeholderState.classList.add('hidden'); placeholderState.classList.add('hidden');
loadingState.classList.add('hidden'); loadingState.classList.add('hidden');
@ -159,49 +247,16 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
document.addEventListener('keydown', handleGenerateShortcut);
document.addEventListener('keydown', handleResetShortcut);
document.addEventListener('keydown', handleDownloadShortcut);
function handleGenerateShortcut(event) {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault();
if (generateBtn && !generateBtn.disabled) {
generateBtn.click();
}
}
}
if (imageDisplayArea) {
imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false });
imageDisplayArea.addEventListener('pointerdown', handleCanvasPointerDown);
}
if (canvasToolbar) {
canvasToolbar.addEventListener('click', handleCanvasToolbarClick);
}
document.addEventListener('pointermove', handleCanvasPointerMove);
document.addEventListener('pointerup', () => {
if (isPanning && imageDisplayArea) {
imageDisplayArea.style.cursor = 'grab';
}
isPanning = false;
});
document.addEventListener('pointerleave', () => {
if (isPanning && imageDisplayArea) {
imageDisplayArea.style.cursor = 'grab';
}
isPanning = false;
});
function showError(message) { function showError(message) {
errorText.textContent = message; errorText.textContent = message;
setViewState('error'); setViewState('error');
} }
function displayImage(imageUrl, imageData) { function displayImage(imageUrl, imageData) {
const cacheBustedUrl = withCacheBuster(imageUrl); let cacheBustedUrl = imageUrl;
if (!imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) {
cacheBustedUrl = withCacheBuster(imageUrl);
}
if (imageData) { if (imageData) {
generatedImage.src = `data:image/png;base64,${imageData}`; generatedImage.src = `data:image/png;base64,${imageData}`;
@ -220,47 +275,40 @@ document.addEventListener('DOMContentLoaded', () => {
setViewState('result'); setViewState('result');
} }
async function loadGallery() { async function handleCanvasDropUrl(imageUrl) {
const cleanedUrl = imageUrl;
displayImage(cleanedUrl);
try { try {
const response = await fetch(`/gallery?t=${new Date().getTime()}`); const response = await fetch(withCacheBuster(cleanedUrl));
const data = await response.json(); if (!response.ok) return;
const metadata = await extractMetadataFromBlob(await response.blob());
galleryGrid.innerHTML = ''; if (metadata) {
applyMetadata(metadata);
data.images.forEach(imageUrl => { }
const div = document.createElement('div');
div.className = 'gallery-item';
div.onclick = () => {
displayImage(imageUrl);
// Update active state
document.querySelectorAll('.gallery-item').forEach(el => el.classList.remove('active'));
div.classList.add('active');
};
const img = document.createElement('img');
img.src = withCacheBuster(imageUrl);
img.loading = 'lazy';
img.draggable = true;
img.dataset.source = imageUrl;
img.addEventListener('dragstart', event => {
event.dataTransfer?.setData('text/uri-list', imageUrl);
event.dataTransfer?.setData('text/plain', imageUrl);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy';
}
});
div.appendChild(img);
galleryGrid.appendChild(div);
});
} catch (error) { } catch (error) {
console.error('Failed to load gallery:', error); console.warn('Unable to read metadata from dropped image', error);
} }
} }
function withCacheBuster(url) { function applyMetadata(metadata) {
const separator = url.includes('?') ? '&' : '?'; if (!metadata) return;
return `${url}${separator}t=${new Date().getTime()}`; if (metadata.prompt) promptInput.value = metadata.prompt;
if (metadata.aspect_ratio) aspectRatioInput.value = metadata.aspect_ratio;
if (metadata.resolution) resolutionInput.value = metadata.resolution;
if (metadata.reference_images && Array.isArray(metadata.reference_images)) {
slotManager.setReferenceImages(metadata.reference_images);
}
persistSettings();
}
async function loadGallery() {
try {
await gallery.load();
} catch (error) {
console.error('Unable to populate gallery', error);
}
} }
function buildGenerateFormData(fields) { function buildGenerateFormData(fields) {
@ -272,30 +320,33 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
imageSlotState.forEach(record => { slotManager.getReferenceFiles().forEach(file => {
const slotFile = getSlotFile(record); formData.append('reference_images', file, file.name);
if (slotFile) {
formData.append('reference_images', slotFile, slotFile.name);
}
}); });
const referencePaths = slotManager.getReferencePaths();
if (referencePaths && referencePaths.length > 0) {
formData.append('reference_image_paths', JSON.stringify(referencePaths));
}
return formData; return formData;
} }
function loadSettings() { function loadSettings() {
if (typeof localStorage === 'undefined') return; if (typeof localStorage === 'undefined') return {};
try { try {
const saved = localStorage.getItem(SETTINGS_STORAGE_KEY); const saved = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (!saved) return; if (!saved) return {};
const { apiKey, aspectRatio, resolution, prompt, referenceImages } = JSON.parse(saved); const { apiKey, aspectRatio, resolution, prompt, referenceImages } = JSON.parse(saved);
if (apiKey) apiKeyInput.value = apiKey; if (apiKey) apiKeyInput.value = apiKey;
if (aspectRatio) aspectRatioInput.value = aspectRatio; if (aspectRatio) aspectRatioInput.value = aspectRatio;
if (resolution) resolutionInput.value = resolution; if (resolution) resolutionInput.value = resolution;
if (prompt) promptInput.value = prompt; if (prompt) promptInput.value = prompt;
cachedReferenceImages = Array.isArray(referenceImages) ? referenceImages : []; return { apiKey, aspectRatio, resolution, prompt, referenceImages };
} catch (error) { } catch (error) {
console.warn('Unable to load cached settings', error); console.warn('Unable to load cached settings', error);
return {};
} }
} }
@ -307,7 +358,7 @@ document.addEventListener('DOMContentLoaded', () => {
aspectRatio: aspectRatioInput.value, aspectRatio: aspectRatioInput.value,
resolution: resolutionInput.value, resolution: resolutionInput.value,
prompt: promptInput.value.trim(), prompt: promptInput.value.trim(),
referenceImages: serializeReferenceImages(), referenceImages: slotManager.serializeReferenceImages(),
}; };
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} catch (error) { } catch (error) {
@ -315,6 +366,37 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
function handleGenerateShortcut(event) {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault();
if (generateBtn && !generateBtn.disabled) {
generateBtn.click();
}
}
}
function handleResetShortcut(event) {
if (event.code !== 'Space' && event.key !== ' ') return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const targetTag = event.target?.tagName;
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
if (event.target?.isContentEditable) return;
if (resultState.classList.contains('hidden')) return;
event.preventDefault();
resetView();
}
function handleDownloadShortcut(event) {
if (event.key !== 'd') return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const targetTag = event.target?.tagName;
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
if (event.target?.isContentEditable) return;
if (resultState.classList.contains('hidden')) return;
event.preventDefault();
downloadLink.click();
}
function handleCanvasWheel(event) { function handleCanvasWheel(event) {
if (resultState.classList.contains('hidden')) return; if (resultState.classList.contains('hidden')) return;
event.preventDefault(); event.preventDefault();
@ -390,328 +472,4 @@ document.addEventListener('DOMContentLoaded', () => {
panOffset = { x: 0, y: 0 }; panOffset = { x: 0, y: 0 };
setImageTransform(); setImageTransform();
} }
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function handleResetShortcut(event) {
if (event.key !== 'r') return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const targetTag = event.target?.tagName;
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
if (event.target?.isContentEditable) return;
if (resultState.classList.contains('hidden')) return;
event.preventDefault();
resetView();
}
function handleDownloadShortcut(event) {
if (event.key !== 'd') return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const targetTag = event.target?.tagName;
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
if (event.target?.isContentEditable) return;
if (resultState.classList.contains('hidden')) return;
event.preventDefault();
downloadLink.click();
}
function initializeImageInputs() {
if (!imageInputGrid) return;
const requiredSlots = Math.min(
MAX_IMAGE_SLOTS,
Math.max(INITIAL_IMAGE_SLOTS, cachedReferenceImages.length + 1)
);
for (let i = 0; i < requiredSlots; i++) {
addImageSlot();
}
cachedReferenceImages.forEach((cached, index) => {
applyCachedImageToSlot(index, cached);
});
maybeAddSlot();
}
function applyCachedImageToSlot(index, cached) {
if (!cached || !cached.dataUrl) return;
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = {
file: null,
preview: cached.dataUrl,
cached: {
name: cached.name,
type: cached.type,
dataUrl: cached.dataUrl,
},
};
updateSlotVisual(index);
}
function addImageSlot() {
if (!imageInputGrid || imageSlotState.length >= MAX_IMAGE_SLOTS) return;
const index = imageSlotState.length;
const slotElement = createImageSlotElement(index);
imageSlotState.push({
slot: slotElement,
data: null,
});
imageInputGrid.appendChild(slotElement);
}
function createImageSlotElement(index) {
const slot = document.createElement('div');
slot.className = 'image-slot empty';
slot.dataset.index = index;
const placeholder = document.createElement('div');
placeholder.className = 'slot-placeholder';
placeholder.innerHTML = '<span class="slot-icon">+</span>';
slot.appendChild(placeholder);
const preview = document.createElement('img');
preview.className = 'slot-preview hidden';
preview.alt = 'Uploaded reference';
slot.appendChild(preview);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'slot-remove hidden';
removeBtn.setAttribute('aria-label', 'Remove image');
removeBtn.textContent = '×';
slot.appendChild(removeBtn);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.className = 'slot-input';
slot.appendChild(input);
slot.addEventListener('click', event => {
if (event.target === removeBtn) return;
input.click();
});
input.addEventListener('change', () => {
if (input.files && input.files.length) {
handleSlotFile(index, input.files[0]);
}
});
slot.addEventListener('dragenter', event => {
event.preventDefault();
slot.classList.add('drag-over');
});
slot.addEventListener('dragover', event => {
event.preventDefault();
slot.classList.add('drag-over');
});
slot.addEventListener('dragleave', () => {
slot.classList.remove('drag-over');
});
slot.addEventListener('drop', async event => {
event.preventDefault();
slot.classList.remove('drag-over');
const file = event.dataTransfer?.files?.[0];
if (file) {
handleSlotFile(index, file);
return;
}
const imageUrl = event.dataTransfer?.getData('text/uri-list')
|| event.dataTransfer?.getData('text/plain');
if (imageUrl) {
await handleSlotDropFromHistory(index, imageUrl);
}
});
removeBtn.addEventListener('click', event => {
event.stopPropagation();
clearSlot(index);
});
return slot;
}
function handleSlotFile(index, file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const previewUrl = reader.result;
if (typeof previewUrl !== 'string') return;
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = {
file,
preview: previewUrl,
cached: null,
};
updateSlotVisual(index);
persistSettings();
maybeAddSlot();
};
reader.readAsDataURL(file);
}
async function handleSlotDropFromHistory(index, imageUrl) {
try {
const response = await fetch(withCacheBuster(imageUrl));
if (!response.ok) {
console.warn('Failed to fetch history image', response.statusText);
return;
}
const blob = await response.blob();
const name = imageUrl.split('/').pop()?.split('?')[0] || `history-${index + 1}.png`;
const type = blob.type || 'image/png';
const file = new File([blob], name, { type });
handleSlotFile(index, file);
} catch (error) {
console.error('Unable to import history image', error);
}
}
function updateSlotVisual(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
const slot = slotRecord.slot;
const placeholder = slot.querySelector('.slot-placeholder');
const preview = slot.querySelector('.slot-preview');
const removeBtn = slot.querySelector('.slot-remove');
if (slotRecord.data && slotRecord.data.preview) {
preview.src = slotRecord.data.preview;
preview.classList.remove('hidden');
placeholder.classList.add('hidden');
removeBtn.classList.remove('hidden');
slot.classList.add('filled');
slot.classList.remove('empty');
} else {
preview.src = '';
preview.classList.add('hidden');
placeholder.classList.remove('hidden');
removeBtn.classList.add('hidden');
slot.classList.add('empty');
slot.classList.remove('filled');
}
}
function clearSlot(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
slotRecord.data = null;
const input = slotRecord.slot.querySelector('.slot-input');
if (input) input.value = '';
updateSlotVisual(index);
persistSettings();
}
function maybeAddSlot() {
const hasEmpty = imageSlotState.some(record => !record.data);
if (!hasEmpty && imageSlotState.length < MAX_IMAGE_SLOTS) {
addImageSlot();
}
}
function serializeReferenceImages() {
return imageSlotState
.map((record, index) => {
if (!record.data || !record.data.preview) return null;
const name = record.data.cached?.name || record.data.file?.name || `reference-${index + 1}.png`;
const type = record.data.cached?.type || record.data.file?.type || 'image/png';
return {
name,
type,
dataUrl: record.data.preview,
};
})
.filter(Boolean);
}
function getSlotFile(record) {
if (!record.data) return null;
if (record.data.file) return record.data.file;
if (record.data.cached && record.data.cached.dataUrl) {
const blob = dataUrlToBlob(record.data.cached.dataUrl);
if (!blob) return null;
const fileName = record.data.cached.name || `reference.png`;
const fileType = record.data.cached.type || 'image/png';
return new File([blob], fileName, { type: fileType });
}
return null;
}
function dataUrlToBlob(dataUrl) {
try {
const [prefix, base64] = dataUrl.split(',');
const mimeMatch = prefix.match(/:(.*?);/);
const mime = mimeMatch ? mimeMatch[1] : 'image/png';
const binary = atob(base64);
const len = binary.length;
const buffer = new Uint8Array(len);
for (let i = 0; i < len; i++) {
buffer[i] = binary.charCodeAt(i);
}
return new Blob([buffer], { type: mime });
} catch (error) {
console.warn('Unable to convert cached image to blob', error);
return null;
}
}
popupButtons.forEach(button => {
button.addEventListener('click', () => {
const target = button.dataset.popupTarget;
if (target) {
showPopup(target);
}
});
});
if (popupCloseBtn) {
popupCloseBtn.addEventListener('click', closePopup);
}
if (popupOverlay) {
popupOverlay.addEventListener('click', event => {
if (event.target === popupOverlay) {
closePopup();
}
});
}
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && popupOverlay && !popupOverlay.classList.contains('hidden')) {
event.preventDefault();
closePopup();
}
});
function showPopup(type) {
const content = POPUP_CONTENT[type];
if (!content || !popupOverlay || !popupBodyEl || !popupTitleEl) return;
popupTitleEl.textContent = content.title;
popupBodyEl.innerHTML = content.sections
.map(section => {
const items = (section.items || []).map(item => `<li>${item}</li>`).join('');
return `
<section class="popup-section">
<h3>${section.heading}</h3>
<ul>${items}</ul>
</section>
`;
})
.join('');
popupOverlay.classList.remove('hidden');
}
function closePopup() {
if (!popupOverlay) return;
popupOverlay.classList.add('hidden');
}
}); });

View file

@ -32,6 +32,17 @@ body {
transition: background 0.6s ease; transition: background 0.6s ease;
} }
a {
color: var(--accent-color);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.app-container { .app-container {
display: flex; display: flex;
height: 100vh; height: 100vh;
@ -423,6 +434,13 @@ button#generate-btn:disabled {
min-height: 0; min-height: 0;
} }
.image-display-area.drag-over {
border-color: rgba(251, 191, 36, 0.8);
box-shadow: 0 30px 60px rgba(9, 5, 25, 0.7), inset 0 0 60px rgba(250, 204, 21, 0.25);
background: radial-gradient(circle at top, rgba(255, 255, 255, 0.08), rgba(6, 6, 18, 0.95));
backdrop-filter: blur(8px);
}
.state-view { .state-view {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -675,6 +693,35 @@ button#generate-btn:disabled {
object-fit: cover; object-fit: cover;
} }
.gallery-item .delete-btn {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.2s;
font-size: 14px;
line-height: 1;
z-index: 10;
}
.gallery-item:hover .delete-btn {
opacity: 1;
}
.gallery-item .delete-btn:hover {
background: rgba(255, 68, 68, 0.9);
}
/* Spinner */ /* Spinner */
.spinner { .spinner {
width: 50px; width: 50px;

View file

@ -129,7 +129,7 @@
<div id="popup-body" class="popup-body"></div> <div id="popup-body" class="popup-body"></div>
</div> </div>
</div> </div>
<script src="{{ url_for('static', filename='script.js') }}"></script> <script type="module" src="{{ url_for('static', filename='script.js') }}"></script>
</body> </body>
</html> </html>