mirror of
https://github.com/yusufipk/imagen-openrouter.git
synced 2026-09-11 10:36:07 +00:00
feat: major UX improvements and polish
- Allow multiple concurrent image generation requests - Track pending batches with loading placeholders in gallery - Show progress for each batch independently - Warn before leaving page with pending generations - Add clipboard paste support for reference images - Paste images directly with Ctrl+V/Cmd+V - Works from screenshots, copied images, etc. - Add download button on image card hover - Quick download without opening modal - Timestamped filenames for organization - Replace all emoji icons with clean SVG icons - Professional Lucide/Feather-style icons - Logo, buttons, loading states, empty states - Consistent visual language throughout - Persist image count setting across refreshes - Save to localStorage on change - Properly initialize input value on load - Remove blocking loading indicator - Generate button stays enabled during generation - Each request shows inline loading placeholders
This commit is contained in:
+37
-11
@@ -14,7 +14,15 @@
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1 class="logo">🎨 Imagen</h1>
|
<h1 class="logo">
|
||||||
|
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 19l7-7 3 3-7 7-3-3z"></path>
|
||||||
|
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path>
|
||||||
|
<path d="M2 2l7.586 7.586"></path>
|
||||||
|
<circle cx="11" cy="11" r="2"></circle>
|
||||||
|
</svg>
|
||||||
|
Imagen
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reference Images Section -->
|
<!-- Reference Images Section -->
|
||||||
@@ -114,11 +122,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Loading Indicator -->
|
|
||||||
<div class="loading-container" id="loadingContainer" style="display: none;">
|
|
||||||
<div class="loading-spinner"></div>
|
|
||||||
<span class="loading-text">Generating images...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gallery -->
|
<!-- Gallery -->
|
||||||
@@ -130,7 +133,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gallery" id="gallery">
|
<div class="gallery" id="gallery">
|
||||||
<div class="gallery-empty" id="galleryEmpty">
|
<div class="gallery-empty" id="galleryEmpty">
|
||||||
<span class="empty-icon">🖼️</span>
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||||
|
<polyline points="21 15 16 10 5 21"></polyline>
|
||||||
|
</svg>
|
||||||
<p>No images generated yet</p>
|
<p>No images generated yet</p>
|
||||||
<p class="empty-hint">Write a prompt above and click Generate</p>
|
<p class="empty-hint">Write a prompt above and click Generate</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,17 +149,36 @@
|
|||||||
<div class="modal" id="imageModal">
|
<div class="modal" id="imageModal">
|
||||||
<div class="modal-overlay" id="modalOverlay"></div>
|
<div class="modal-overlay" id="modalOverlay"></div>
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<button class="modal-close" id="modalClose">×</button>
|
<button class="modal-close" id="modalClose">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<img src="" alt="Full size image" id="modalImage">
|
<img src="" alt="Full size image" id="modalImage">
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="useAsReference">
|
<button class="btn btn-secondary" id="useAsReference">
|
||||||
<span>📌</span> Use as Reference
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||||
|
<circle cx="12" cy="10" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
Use as Reference
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" id="recreateImage">
|
<button class="btn btn-secondary" id="recreateImage">
|
||||||
<span>🔄</span> Recreate
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<polyline points="1 20 1 14 7 14"></polyline>
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||||
|
</svg>
|
||||||
|
Recreate
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" id="downloadImage">
|
<button class="btn btn-secondary" id="downloadImage">
|
||||||
<span>⬇️</span> Download
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-metadata" id="modalMetadata">
|
<div class="modal-metadata" id="modalMetadata">
|
||||||
|
|||||||
+188
-25
@@ -93,11 +93,11 @@ const state = {
|
|||||||
imageSize: localStorage.getItem('imagen_size') || '1024x1024',
|
imageSize: localStorage.getItem('imagen_size') || '1024x1024',
|
||||||
imageQuality: localStorage.getItem('imagen_quality') || '1K',
|
imageQuality: localStorage.getItem('imagen_quality') || '1K',
|
||||||
aspectRatio: localStorage.getItem('imagen_aspect_ratio') || '1:1',
|
aspectRatio: localStorage.getItem('imagen_aspect_ratio') || '1:1',
|
||||||
imageCount: 1,
|
imageCount: parseInt(localStorage.getItem('imagen_count')) || 1,
|
||||||
references: [], // Dynamic array - unlimited references
|
references: [], // Dynamic array - unlimited references
|
||||||
images: [], // Will be loaded from IndexedDB
|
images: [], // Will be loaded from IndexedDB
|
||||||
currentImage: null,
|
currentImage: null,
|
||||||
isGenerating: false
|
pendingBatches: [] // Track pending generation batches { id, prompt, count, completed, failed }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Model Configurations =====
|
// ===== Model Configurations =====
|
||||||
@@ -215,7 +215,6 @@ const elements = {
|
|||||||
promptInput: document.getElementById('promptInput'),
|
promptInput: document.getElementById('promptInput'),
|
||||||
charCount: document.getElementById('charCount'),
|
charCount: document.getElementById('charCount'),
|
||||||
generateBtn: document.getElementById('generateBtn'),
|
generateBtn: document.getElementById('generateBtn'),
|
||||||
loadingContainer: document.getElementById('loadingContainer'),
|
|
||||||
gallery: document.getElementById('gallery'),
|
gallery: document.getElementById('gallery'),
|
||||||
galleryEmpty: document.getElementById('galleryEmpty'),
|
galleryEmpty: document.getElementById('galleryEmpty'),
|
||||||
clearGallery: document.getElementById('clearGallery'),
|
clearGallery: document.getElementById('clearGallery'),
|
||||||
@@ -267,6 +266,11 @@ async function init() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore saved image count
|
||||||
|
if (elements.imageCount) {
|
||||||
|
elements.imageCount.value = state.imageCount;
|
||||||
|
}
|
||||||
|
|
||||||
// Load images from IndexedDB
|
// Load images from IndexedDB
|
||||||
try {
|
try {
|
||||||
state.images = await ImagenDB.getAllImages();
|
state.images = await ImagenDB.getAllImages();
|
||||||
@@ -340,6 +344,7 @@ function setupEventListeners() {
|
|||||||
if (state.imageCount > 1) {
|
if (state.imageCount > 1) {
|
||||||
state.imageCount--;
|
state.imageCount--;
|
||||||
elements.imageCount.value = state.imageCount;
|
elements.imageCount.value = state.imageCount;
|
||||||
|
localStorage.setItem('imagen_count', state.imageCount);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -349,6 +354,7 @@ function setupEventListeners() {
|
|||||||
if (state.imageCount < 8) {
|
if (state.imageCount < 8) {
|
||||||
state.imageCount++;
|
state.imageCount++;
|
||||||
elements.imageCount.value = state.imageCount;
|
elements.imageCount.value = state.imageCount;
|
||||||
|
localStorage.setItem('imagen_count', state.imageCount);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -360,6 +366,7 @@ function setupEventListeners() {
|
|||||||
if (val > 8) val = 8;
|
if (val > 8) val = 8;
|
||||||
state.imageCount = val;
|
state.imageCount = val;
|
||||||
elements.imageCount.value = val;
|
elements.imageCount.value = val;
|
||||||
|
localStorage.setItem('imagen_count', state.imageCount);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +417,57 @@ function setupEventListeners() {
|
|||||||
if (e.key === 'Escape') closeModal();
|
if (e.key === 'Escape') closeModal();
|
||||||
if (e.key === 'Enter' && e.ctrlKey) generateImages();
|
if (e.key === 'Enter' && e.ctrlKey) generateImages();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Paste images from clipboard
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
|
||||||
|
// Warn user before leaving if there are pending generations
|
||||||
|
window.addEventListener('beforeunload', (e) => {
|
||||||
|
if (state.pendingBatches.length > 0) {
|
||||||
|
const pendingCount = state.pendingBatches.reduce((sum, batch) => {
|
||||||
|
return sum + (batch.count - batch.completed - batch.failed);
|
||||||
|
}, 0);
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Modern browsers ignore custom messages, but we need to return something
|
||||||
|
e.returnValue = `You have ${pendingCount} image(s) still generating. If you leave, they will be lost.`;
|
||||||
|
return e.returnValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Paste Handler =====
|
||||||
|
function handlePaste(e) {
|
||||||
|
// Don't intercept paste if user is typing in an input field (except prompt)
|
||||||
|
const activeEl = document.activeElement;
|
||||||
|
if (activeEl && activeEl.tagName === 'INPUT' && activeEl.type !== 'text') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
let imageCount = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
state.references.push(event.target.result);
|
||||||
|
renderReferenceSlots();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
imageCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageCount > 0) {
|
||||||
|
showToast(`${imageCount} image(s) pasted as reference`, 'success');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Drag & Drop =====
|
// ===== Drag & Drop =====
|
||||||
@@ -488,7 +546,12 @@ function renderReferenceSlots() {
|
|||||||
slot.dataset.slot = index;
|
slot.dataset.slot = index;
|
||||||
slot.innerHTML = `
|
slot.innerHTML = `
|
||||||
<img src="${ref}" alt="Reference ${index + 1}">
|
<img src="${ref}" alt="Reference ${index + 1}">
|
||||||
<button class="remove-ref" data-index="${index}">×</button>
|
<button class="remove-ref" data-index="${index}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
`;
|
`;
|
||||||
container.appendChild(slot);
|
container.appendChild(slot);
|
||||||
});
|
});
|
||||||
@@ -543,14 +606,29 @@ async function generateImages() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.isGenerating = true;
|
|
||||||
elements.generateBtn.disabled = true;
|
|
||||||
elements.loadingContainer.style.display = 'flex';
|
|
||||||
|
|
||||||
const modelConfig = MODEL_CONFIGS[state.selectedModel];
|
const modelConfig = MODEL_CONFIGS[state.selectedModel];
|
||||||
const currentReferences = state.references.length > 0 ? [...state.references] : [];
|
const currentReferences = state.references.length > 0 ? [...state.references] : [];
|
||||||
let successCount = 0;
|
const currentModel = state.selectedModel;
|
||||||
let failCount = 0;
|
const currentSize = state.imageSize;
|
||||||
|
const currentQuality = state.imageQuality;
|
||||||
|
const currentAspectRatio = state.aspectRatio;
|
||||||
|
const imageCount = state.imageCount;
|
||||||
|
|
||||||
|
// Create a batch to track this generation request
|
||||||
|
const batchId = Date.now() + Math.random();
|
||||||
|
const batch = {
|
||||||
|
id: batchId,
|
||||||
|
prompt: prompt,
|
||||||
|
model: currentModel,
|
||||||
|
modelName: modelConfig.name,
|
||||||
|
count: imageCount,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0
|
||||||
|
};
|
||||||
|
state.pendingBatches.push(batch);
|
||||||
|
renderGallery(); // Show loading placeholders
|
||||||
|
|
||||||
|
showToast(`Queued ${imageCount} image(s) for generation`, 'success');
|
||||||
|
|
||||||
// Generate images and display each one as it completes
|
// Generate images and display each one as it completes
|
||||||
const generateAndDisplay = async (index) => {
|
const generateAndDisplay = async (index) => {
|
||||||
@@ -558,45 +636,52 @@ async function generateImages() {
|
|||||||
const result = await generateSingleImage(prompt, modelConfig);
|
const result = await generateSingleImage(prompt, modelConfig);
|
||||||
if (result) {
|
if (result) {
|
||||||
const imageData = {
|
const imageData = {
|
||||||
id: Date.now() + index,
|
id: Date.now() + index + Math.random(),
|
||||||
url: result,
|
url: result,
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
model: state.selectedModel,
|
model: currentModel,
|
||||||
modelName: modelConfig.name,
|
modelName: modelConfig.name,
|
||||||
size: state.imageSize,
|
size: currentSize,
|
||||||
quality: state.imageQuality,
|
quality: currentQuality,
|
||||||
aspectRatio: state.aspectRatio,
|
aspectRatio: currentAspectRatio,
|
||||||
references: currentReferences,
|
references: currentReferences,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
state.images.unshift(imageData);
|
state.images.unshift(imageData);
|
||||||
|
batch.completed++;
|
||||||
renderGallery();
|
renderGallery();
|
||||||
successCount++;
|
|
||||||
|
|
||||||
// Save to IndexedDB in background
|
// Save to IndexedDB in background
|
||||||
ImagenDB.saveImage(imageData).catch(e => console.error('Failed to save to IndexedDB:', e));
|
ImagenDB.saveImage(imageData).catch(e => console.error('Failed to save to IndexedDB:', e));
|
||||||
|
} else {
|
||||||
|
batch.failed++;
|
||||||
|
renderGallery();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate image:', error);
|
console.error('Failed to generate image:', error);
|
||||||
failCount++;
|
batch.failed++;
|
||||||
|
renderGallery();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start all generations in parallel, each will render when done
|
// Start all generations in parallel, each will render when done
|
||||||
const promises = [];
|
const promises = [];
|
||||||
for (let i = 0; i < state.imageCount; i++) {
|
for (let i = 0; i < imageCount; i++) {
|
||||||
promises.push(generateAndDisplay(i));
|
promises.push(generateAndDisplay(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all to complete to update final UI state
|
// Wait for all to complete to update final UI state
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
state.isGenerating = false;
|
// Remove this batch from pending
|
||||||
elements.generateBtn.disabled = false;
|
const batchIndex = state.pendingBatches.findIndex(b => b.id === batchId);
|
||||||
elements.loadingContainer.style.display = 'none';
|
if (batchIndex !== -1) {
|
||||||
|
state.pendingBatches.splice(batchIndex, 1);
|
||||||
|
}
|
||||||
|
renderGallery();
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (batch.completed > 0) {
|
||||||
showToast(`${successCount} image(s) generated!`, 'success');
|
showToast(`${batch.completed} image(s) generated!`, 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast('Failed to generate images. Check console for details.', 'error');
|
showToast('Failed to generate images. Check console for details.', 'error');
|
||||||
}
|
}
|
||||||
@@ -732,7 +817,10 @@ async function generateSingleImage(prompt, modelConfig) {
|
|||||||
|
|
||||||
// ===== Gallery =====
|
// ===== Gallery =====
|
||||||
function renderGallery() {
|
function renderGallery() {
|
||||||
if (state.images.length === 0) {
|
const hasPending = state.pendingBatches.length > 0;
|
||||||
|
const hasImages = state.images.length > 0;
|
||||||
|
|
||||||
|
if (!hasImages && !hasPending) {
|
||||||
elements.galleryEmpty.style.display = 'flex';
|
elements.galleryEmpty.style.display = 'flex';
|
||||||
elements.gallery.innerHTML = '';
|
elements.gallery.innerHTML = '';
|
||||||
elements.gallery.appendChild(elements.galleryEmpty);
|
elements.gallery.appendChild(elements.galleryEmpty);
|
||||||
@@ -741,6 +829,44 @@ function renderGallery() {
|
|||||||
|
|
||||||
elements.gallery.innerHTML = '';
|
elements.gallery.innerHTML = '';
|
||||||
|
|
||||||
|
// Render loading placeholders for pending batches at the top
|
||||||
|
state.pendingBatches.forEach((batch) => {
|
||||||
|
const pendingCount = batch.count - batch.completed - batch.failed;
|
||||||
|
for (let i = 0; i < pendingCount; i++) {
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'image-card loading-placeholder';
|
||||||
|
const safePrompt = escapeHtml(batch.prompt);
|
||||||
|
const truncatedPrompt = batch.prompt.length > 60 ? batch.prompt.substring(0, 60) + '...' : batch.prompt;
|
||||||
|
placeholder.innerHTML = `
|
||||||
|
<div class="loading-placeholder-content">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span class="loading-placeholder-text">Generating...</span>
|
||||||
|
</div>
|
||||||
|
<div class="image-card-overlay" style="opacity: 1;">
|
||||||
|
<p class="image-card-prompt">${escapeHtml(truncatedPrompt)}</p>
|
||||||
|
<div class="image-card-meta">
|
||||||
|
<span class="meta-tag">${escapeHtml(batch.modelName)}</span>
|
||||||
|
<span class="meta-tag loading-tag">
|
||||||
|
<svg class="spin-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="2" x2="12" y2="6"></line>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="22"></line>
|
||||||
|
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line>
|
||||||
|
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line>
|
||||||
|
<line x1="2" y1="12" x2="6" y2="12"></line>
|
||||||
|
<line x1="18" y1="12" x2="22" y2="12"></line>
|
||||||
|
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line>
|
||||||
|
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line>
|
||||||
|
</svg>
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
elements.gallery.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render existing images
|
||||||
state.images.forEach((image, index) => {
|
state.images.forEach((image, index) => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'image-card';
|
card.className = 'image-card';
|
||||||
@@ -750,7 +876,23 @@ function renderGallery() {
|
|||||||
const safePrompt = escapeHtml(image.prompt);
|
const safePrompt = escapeHtml(image.prompt);
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="image-card-delete" data-index="${index}" title="Delete image">🗑️</button>
|
<div class="image-card-actions">
|
||||||
|
<button class="image-card-btn image-card-download" data-index="${index}" title="Download image">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="image-card-btn image-card-delete" data-index="${index}" title="Delete image">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<img src="${safeUrl}" alt="${safePrompt}" loading="lazy">
|
<img src="${safeUrl}" alt="${safePrompt}" loading="lazy">
|
||||||
<div class="image-card-overlay">
|
<div class="image-card-overlay">
|
||||||
<p class="image-card-prompt">${safePrompt}</p>
|
<p class="image-card-prompt">${safePrompt}</p>
|
||||||
@@ -762,6 +904,13 @@ function renderGallery() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Download button handler
|
||||||
|
const downloadBtn = card.querySelector('.image-card-download');
|
||||||
|
downloadBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadImageByIndex(index);
|
||||||
|
});
|
||||||
|
|
||||||
// Delete button handler
|
// Delete button handler
|
||||||
const deleteBtn = card.querySelector('.image-card-delete');
|
const deleteBtn = card.querySelector('.image-card-delete');
|
||||||
deleteBtn.addEventListener('click', (e) => {
|
deleteBtn.addEventListener('click', (e) => {
|
||||||
@@ -789,6 +938,20 @@ async function deleteImage(index) {
|
|||||||
showToast('Image deleted', 'success');
|
showToast('Image deleted', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadImageByIndex(index) {
|
||||||
|
const image = state.images[index];
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = sanitizeImageUrl(image.url);
|
||||||
|
const timestamp = new Date(image.createdAt).toISOString().replace(/[:.]/g, '-');
|
||||||
|
link.download = `imagen-${timestamp}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
showToast('Image downloaded', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Modal =====
|
// ===== Modal =====
|
||||||
function openModal(image) {
|
function openModal(image) {
|
||||||
state.currentImage = image;
|
state.currentImage = image;
|
||||||
|
|||||||
+121
-11
@@ -89,6 +89,15 @@ body {
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-subtitle {
|
.logo-subtitle {
|
||||||
@@ -182,8 +191,8 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 2px;
|
right: 2px;
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--error);
|
background: var(--error);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -192,13 +201,16 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reference-slot .remove-ref svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.reference-slot.filled:hover .remove-ref {
|
.reference-slot.filled:hover .remove-ref {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -588,6 +600,71 @@ body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Loading Placeholder Cards ===== */
|
||||||
|
.loading-placeholder {
|
||||||
|
min-height: 300px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 2px dashed var(--accent-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
animation: pulse-border 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-border {
|
||||||
|
0%, 100% {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: var(--accent-secondary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder .loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder .image-card-overlay {
|
||||||
|
position: relative;
|
||||||
|
inset: auto;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-tag {
|
||||||
|
background: var(--accent-primary) !important;
|
||||||
|
color: var(--bg-primary) !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-tag .spin-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Gallery ===== */
|
/* ===== Gallery ===== */
|
||||||
.gallery-header {
|
.gallery-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -621,9 +698,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-size: 4rem;
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
opacity: 0.5;
|
opacity: 0.3;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-empty p {
|
.gallery-empty p {
|
||||||
@@ -676,30 +755,45 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card-delete {
|
.image-card-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-card:hover .image-card-delete {
|
.image-card-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card:hover .image-card-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-card-download:hover {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
.image-card-delete:hover {
|
.image-card-delete:hover {
|
||||||
background: var(--error);
|
background: var(--error);
|
||||||
border-color: var(--error);
|
border-color: var(--error);
|
||||||
@@ -785,7 +879,6 @@ body {
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -794,6 +887,11 @@ body {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-close svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-close:hover {
|
.modal-close:hover {
|
||||||
background: var(--error);
|
background: var(--error);
|
||||||
}
|
}
|
||||||
@@ -812,6 +910,18 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-actions .btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-metadata {
|
.modal-metadata {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
|
|||||||
Reference in New Issue
Block a user