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:
+188
-25
@@ -93,11 +93,11 @@ const state = {
|
||||
imageSize: localStorage.getItem('imagen_size') || '1024x1024',
|
||||
imageQuality: localStorage.getItem('imagen_quality') || '1K',
|
||||
aspectRatio: localStorage.getItem('imagen_aspect_ratio') || '1:1',
|
||||
imageCount: 1,
|
||||
imageCount: parseInt(localStorage.getItem('imagen_count')) || 1,
|
||||
references: [], // Dynamic array - unlimited references
|
||||
images: [], // Will be loaded from IndexedDB
|
||||
currentImage: null,
|
||||
isGenerating: false
|
||||
pendingBatches: [] // Track pending generation batches { id, prompt, count, completed, failed }
|
||||
};
|
||||
|
||||
// ===== Model Configurations =====
|
||||
@@ -215,7 +215,6 @@ const elements = {
|
||||
promptInput: document.getElementById('promptInput'),
|
||||
charCount: document.getElementById('charCount'),
|
||||
generateBtn: document.getElementById('generateBtn'),
|
||||
loadingContainer: document.getElementById('loadingContainer'),
|
||||
gallery: document.getElementById('gallery'),
|
||||
galleryEmpty: document.getElementById('galleryEmpty'),
|
||||
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
|
||||
try {
|
||||
state.images = await ImagenDB.getAllImages();
|
||||
@@ -340,6 +344,7 @@ function setupEventListeners() {
|
||||
if (state.imageCount > 1) {
|
||||
state.imageCount--;
|
||||
elements.imageCount.value = state.imageCount;
|
||||
localStorage.setItem('imagen_count', state.imageCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -349,6 +354,7 @@ function setupEventListeners() {
|
||||
if (state.imageCount < 8) {
|
||||
state.imageCount++;
|
||||
elements.imageCount.value = state.imageCount;
|
||||
localStorage.setItem('imagen_count', state.imageCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -360,6 +366,7 @@ function setupEventListeners() {
|
||||
if (val > 8) val = 8;
|
||||
state.imageCount = 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 === '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 =====
|
||||
@@ -488,7 +546,12 @@ function renderReferenceSlots() {
|
||||
slot.dataset.slot = index;
|
||||
slot.innerHTML = `
|
||||
<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);
|
||||
});
|
||||
@@ -543,14 +606,29 @@ async function generateImages() {
|
||||
return;
|
||||
}
|
||||
|
||||
state.isGenerating = true;
|
||||
elements.generateBtn.disabled = true;
|
||||
elements.loadingContainer.style.display = 'flex';
|
||||
|
||||
const modelConfig = MODEL_CONFIGS[state.selectedModel];
|
||||
const currentReferences = state.references.length > 0 ? [...state.references] : [];
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const currentModel = state.selectedModel;
|
||||
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
|
||||
const generateAndDisplay = async (index) => {
|
||||
@@ -558,45 +636,52 @@ async function generateImages() {
|
||||
const result = await generateSingleImage(prompt, modelConfig);
|
||||
if (result) {
|
||||
const imageData = {
|
||||
id: Date.now() + index,
|
||||
id: Date.now() + index + Math.random(),
|
||||
url: result,
|
||||
prompt: prompt,
|
||||
model: state.selectedModel,
|
||||
model: currentModel,
|
||||
modelName: modelConfig.name,
|
||||
size: state.imageSize,
|
||||
quality: state.imageQuality,
|
||||
aspectRatio: state.aspectRatio,
|
||||
size: currentSize,
|
||||
quality: currentQuality,
|
||||
aspectRatio: currentAspectRatio,
|
||||
references: currentReferences,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
state.images.unshift(imageData);
|
||||
batch.completed++;
|
||||
renderGallery();
|
||||
successCount++;
|
||||
|
||||
// Save to IndexedDB in background
|
||||
ImagenDB.saveImage(imageData).catch(e => console.error('Failed to save to IndexedDB:', e));
|
||||
} else {
|
||||
batch.failed++;
|
||||
renderGallery();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate image:', error);
|
||||
failCount++;
|
||||
batch.failed++;
|
||||
renderGallery();
|
||||
}
|
||||
};
|
||||
|
||||
// Start all generations in parallel, each will render when done
|
||||
const promises = [];
|
||||
for (let i = 0; i < state.imageCount; i++) {
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
promises.push(generateAndDisplay(i));
|
||||
}
|
||||
|
||||
// Wait for all to complete to update final UI state
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
state.isGenerating = false;
|
||||
elements.generateBtn.disabled = false;
|
||||
elements.loadingContainer.style.display = 'none';
|
||||
// Remove this batch from pending
|
||||
const batchIndex = state.pendingBatches.findIndex(b => b.id === batchId);
|
||||
if (batchIndex !== -1) {
|
||||
state.pendingBatches.splice(batchIndex, 1);
|
||||
}
|
||||
renderGallery();
|
||||
|
||||
if (successCount > 0) {
|
||||
showToast(`${successCount} image(s) generated!`, 'success');
|
||||
if (batch.completed > 0) {
|
||||
showToast(`${batch.completed} image(s) generated!`, 'success');
|
||||
} else {
|
||||
showToast('Failed to generate images. Check console for details.', 'error');
|
||||
}
|
||||
@@ -732,7 +817,10 @@ async function generateSingleImage(prompt, modelConfig) {
|
||||
|
||||
// ===== Gallery =====
|
||||
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.gallery.innerHTML = '';
|
||||
elements.gallery.appendChild(elements.galleryEmpty);
|
||||
@@ -741,6 +829,44 @@ function renderGallery() {
|
||||
|
||||
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) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'image-card';
|
||||
@@ -750,7 +876,23 @@ function renderGallery() {
|
||||
const safePrompt = escapeHtml(image.prompt);
|
||||
|
||||
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">
|
||||
<div class="image-card-overlay">
|
||||
<p class="image-card-prompt">${safePrompt}</p>
|
||||
@@ -762,6 +904,13 @@ function renderGallery() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Download button handler
|
||||
const downloadBtn = card.querySelector('.image-card-download');
|
||||
downloadBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
downloadImageByIndex(index);
|
||||
});
|
||||
|
||||
// Delete button handler
|
||||
const deleteBtn = card.querySelector('.image-card-delete');
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
@@ -789,6 +938,20 @@ async function deleteImage(index) {
|
||||
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 =====
|
||||
function openModal(image) {
|
||||
state.currentImage = image;
|
||||
|
||||
+121
-11
@@ -89,6 +89,15 @@ body {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
@@ -182,8 +191,8 @@ body {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--error);
|
||||
color: white;
|
||||
@@ -192,13 +201,16 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.reference-slot .remove-ref svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.reference-slot.filled:hover .remove-ref {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -588,6 +600,71 @@ body {
|
||||
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-header {
|
||||
display: flex;
|
||||
@@ -621,9 +698,11 @@ body {
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
opacity: 0.3;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gallery-empty p {
|
||||
@@ -676,30 +755,45 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-card-delete {
|
||||
.image-card-actions {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.image-card-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.image-card-download:hover {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.image-card-delete:hover {
|
||||
background: var(--error);
|
||||
border-color: var(--error);
|
||||
@@ -785,7 +879,6 @@ body {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -794,6 +887,11 @@ body {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--error);
|
||||
}
|
||||
@@ -812,6 +910,18 @@ body {
|
||||
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 {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
Reference in New Issue
Block a user