diff --git a/.gitignore b/.gitignore
index 6261338..15a661e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
.DS_Store
.DS_Store
/.venv
+/static/uploads
diff --git a/app.py b/app.py
index 3351b86..0d5f964 100644
--- a/app.py
+++ b/app.py
@@ -2,10 +2,12 @@ import os
import base64
import uuid
import glob
+import json
+from io import BytesIO
from flask import Flask, render_template, request, jsonify, url_for
from google import genai
from google.genai import types
-from PIL import Image
+from PIL import Image, PngImagePlugin
app = Flask(__name__)
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')
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('/')
def index():
return render_template('index.html')
@@ -29,6 +35,7 @@ def generate_image():
resolution = form.get('resolution', '2K')
api_key = form.get('api_key') or os.environ.get('GOOGLE_API_KEY')
reference_files = request.files.getlist('reference_images')
+ reference_paths_json = form.get('reference_image_paths')
else:
data = request.get_json() or {}
prompt = data.get('prompt')
@@ -36,6 +43,7 @@ def generate_image():
resolution = data.get('resolution', '2K')
api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY')
reference_files = []
+ reference_paths_json = data.get('reference_image_paths')
if not prompt:
return jsonify({'error': 'Prompt is required'}), 400
@@ -53,14 +61,89 @@ def generate_image():
if aspect_ratio and aspect_ratio != 'Auto':
image_config_args["aspect_ratio"] = aspect_ratio
+ # Process reference paths and files
+ final_reference_paths = []
contents = [prompt]
- for reference in reference_files:
+
+ # Parse reference paths from frontend
+ frontend_paths = []
+ if reference_paths_json:
try:
- reference.stream.seek(0)
- reference_image = Image.open(reference.stream)
- contents.append(reference_image)
- except Exception:
- continue
+ frontend_paths = json.loads(reference_paths_json)
+ except json.JSONDecodeError:
+ pass
+
+ # 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(
model="gemini-3-pro-image-preview",
@@ -74,18 +157,37 @@ def generate_image():
for part in response.parts:
if part.inline_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"
filepath = os.path.join(GENERATED_DIR, filename)
- with open(filepath, "wb") as f:
- f.write(image_bytes)
-
- image_url = url_for('static', filename=f'generated/{filename}')
- image_data = base64.b64encode(image_bytes).decode('utf-8')
+ rel_path = os.path.join('generated', filename)
+ image_url = url_for('static', filename=rel_path)
+
+ metadata = {
+ '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({
'image': image_url,
'image_data': image_data,
+ 'metadata': metadata,
})
return jsonify({'error': 'No image generated'}), 500
@@ -93,6 +195,27 @@ def generate_image():
except Exception as e:
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')
def get_gallery():
# List all png files in generated dir, sorted by modification time (newest first)
diff --git a/static/modules/gallery.js b/static/modules/gallery.js
new file mode 100644
index 0000000..6cf33b4
--- /dev/null
+++ b/static/modules/gallery.js
@@ -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 };
+}
diff --git a/static/modules/metadata.js b/static/modules/metadata.js
new file mode 100644
index 0000000..cc34cef
--- /dev/null
+++ b/static/modules/metadata.js
@@ -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;
+}
diff --git a/static/modules/popup.js b/static/modules/popup.js
new file mode 100644
index 0000000..a1cab10
--- /dev/null
+++ b/static/modules/popup.js
@@ -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 => `
${item}`).join('');
+ return `
+
+ `;
+ })
+ .join('');
+
+ overlay.classList.remove('hidden');
+ }
+
+ function closePopup() {
+ overlay.classList.add('hidden');
+ }
+}
diff --git a/static/modules/referenceSlots.js b/static/modules/referenceSlots.js
new file mode 100644
index 0000000..9ddd6f4
--- /dev/null
+++ b/static/modules/referenceSlots.js
@@ -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 = '+';
+ 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,
+ };
+}
diff --git a/static/modules/utils.js b/static/modules/utils.js
new file mode 100644
index 0000000..3732ede
--- /dev/null
+++ b/static/modules/utils.js
@@ -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;
+ }
+}
diff --git a/static/script.js b/static/script.js
index 2205da3..0a08ef2 100644
--- a/static/script.js
+++ b/static/script.js
@@ -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: SDVN - Cộng đồng AI Art',
+ 'Website: sdvn.vn',
+ ],
+ },
+ ],
+};
+
+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', () => {
const generateBtn = document.getElementById('generate-btn');
const promptInput = document.getElementById('prompt');
const aspectRatioInput = document.getElementById('aspect-ratio');
const resolutionInput = document.getElementById('resolution');
const apiKeyInput = document.getElementById('api-key');
-
- // States
+
const placeholderState = document.getElementById('placeholder-state');
const loadingState = document.getElementById('loading-state');
const errorState = document.getElementById('error-state');
const resultState = document.getElementById('result-state');
-
const errorText = document.getElementById('error-text');
const generatedImage = document.getElementById('generated-image');
const downloadLink = document.getElementById('download-link');
const galleryGrid = document.getElementById('gallery-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 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 panOffset = { x: 0, y: 0 };
let isPanning = false;
let lastPointer = { x: 0, y: 0 };
- const infoContent = {
- title: 'Thông tin',
- sections: [
- {
- heading: 'Liên hệ',
- items: [
- 'Người tạo: Phạm Hưng',
- 'Group: SDVN - Cộng đồng AI Art',
- 'Website: sdvn.vn',
- ],
- },
- ],
- };
+ const slotManager = createReferenceSlotManager(imageInputGrid, {
+ onChange: persistSettings,
+ });
- 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',
- '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 gallery = createGallery({
+ galleryGrid,
+ onSelect: async ({ imageUrl, metadata }) => {
+ displayImage(imageUrl);
+ if (metadata) {
+ applyMetadata(metadata);
+ }
+ },
+ });
- const POPUP_CONTENT = {
- info: infoContent,
- docs: docsContent,
- };
+ setupHelpPopups({
+ buttonsSelector: '[data-popup-target]',
+ overlayId: 'popup-overlay',
+ titleId: 'popup-title',
+ bodyId: 'popup-body',
+ closeBtnId: 'popup-close',
+ content: POPUP_CONTENT,
+ });
- // Load gallery on start
- loadSettings();
- initializeImageInputs();
- loadGallery();
+ const savedSettings = loadSettings();
+ slotManager.initialize(savedSettings.referenceImages || []);
apiKeyInput.addEventListener('input', persistSettings);
promptInput.addEventListener('input', persistSettings);
@@ -99,7 +118,6 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
- // Set UI to loading
setViewState('loading');
generateBtn.disabled = true;
@@ -124,12 +142,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (data.image) {
displayImage(data.image, data.image_data);
- // Refresh gallery to show new image
- loadGallery();
+ gallery.load();
} else {
throw new Error('No image data received');
}
-
} catch (error) {
showError(error.message);
} 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) {
placeholderState.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) {
errorText.textContent = message;
setViewState('error');
}
function displayImage(imageUrl, imageData) {
- const cacheBustedUrl = withCacheBuster(imageUrl);
+ let cacheBustedUrl = imageUrl;
+ if (!imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) {
+ cacheBustedUrl = withCacheBuster(imageUrl);
+ }
if (imageData) {
generatedImage.src = `data:image/png;base64,${imageData}`;
@@ -220,47 +275,40 @@ document.addEventListener('DOMContentLoaded', () => {
setViewState('result');
}
- async function loadGallery() {
+ async function handleCanvasDropUrl(imageUrl) {
+ const cleanedUrl = imageUrl;
+ displayImage(cleanedUrl);
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';
- 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);
- });
+ const response = await fetch(withCacheBuster(cleanedUrl));
+ if (!response.ok) return;
+ const metadata = await extractMetadataFromBlob(await response.blob());
+ if (metadata) {
+ applyMetadata(metadata);
+ }
} catch (error) {
- console.error('Failed to load gallery:', error);
+ console.warn('Unable to read metadata from dropped image', error);
}
}
- function withCacheBuster(url) {
- const separator = url.includes('?') ? '&' : '?';
- return `${url}${separator}t=${new Date().getTime()}`;
+ function applyMetadata(metadata) {
+ if (!metadata) return;
+ 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) {
@@ -272,30 +320,33 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
- imageSlotState.forEach(record => {
- const slotFile = getSlotFile(record);
- if (slotFile) {
- formData.append('reference_images', slotFile, slotFile.name);
- }
+ slotManager.getReferenceFiles().forEach(file => {
+ formData.append('reference_images', file, file.name);
});
+
+ const referencePaths = slotManager.getReferencePaths();
+ if (referencePaths && referencePaths.length > 0) {
+ formData.append('reference_image_paths', JSON.stringify(referencePaths));
+ }
return formData;
}
function loadSettings() {
- if (typeof localStorage === 'undefined') return;
+ if (typeof localStorage === 'undefined') return {};
try {
const saved = localStorage.getItem(SETTINGS_STORAGE_KEY);
- if (!saved) return;
+ if (!saved) return {};
const { apiKey, aspectRatio, resolution, prompt, referenceImages } = JSON.parse(saved);
if (apiKey) apiKeyInput.value = apiKey;
if (aspectRatio) aspectRatioInput.value = aspectRatio;
if (resolution) resolutionInput.value = resolution;
if (prompt) promptInput.value = prompt;
- cachedReferenceImages = Array.isArray(referenceImages) ? referenceImages : [];
+ return { apiKey, aspectRatio, resolution, prompt, referenceImages };
} catch (error) {
console.warn('Unable to load cached settings', error);
+ return {};
}
}
@@ -307,7 +358,7 @@ document.addEventListener('DOMContentLoaded', () => {
aspectRatio: aspectRatioInput.value,
resolution: resolutionInput.value,
prompt: promptInput.value.trim(),
- referenceImages: serializeReferenceImages(),
+ referenceImages: slotManager.serializeReferenceImages(),
};
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} 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) {
if (resultState.classList.contains('hidden')) return;
event.preventDefault();
@@ -390,328 +472,4 @@ document.addEventListener('DOMContentLoaded', () => {
panOffset = { x: 0, y: 0 };
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 = '+';
- 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 => `${item}`).join('');
- return `
-
- `;
- })
- .join('');
-
- popupOverlay.classList.remove('hidden');
- }
-
- function closePopup() {
- if (!popupOverlay) return;
- popupOverlay.classList.add('hidden');
- }
});
diff --git a/static/style.css b/static/style.css
index 0486df4..a04efc9 100644
--- a/static/style.css
+++ b/static/style.css
@@ -32,6 +32,17 @@ body {
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 {
display: flex;
height: 100vh;
@@ -423,6 +434,13 @@ button#generate-btn:disabled {
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 {
width: 100%;
height: 100%;
@@ -675,6 +693,35 @@ button#generate-btn:disabled {
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 {
width: 50px;
diff --git a/templates/index.html b/templates/index.html
index 6b20d7b..5dba4f5 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -129,7 +129,7 @@
-
+