feat: Initial release of Imagen - AI Image Generation Tool

A client-side AI image generation tool using OpenRouter API.

Features:
- Multi-model support (Gemini, GPT-5, Flux 2, Seedream, Riverflow)
- Unlimited reference image uploads with drag & drop
- IndexedDB storage for persistent image gallery
- Adjustable resolution (1K/2K/4K) and aspect ratios
- Batch generation (up to 8 images)
- Custom styled dropdown for model selection
- Gallery with delete functionality on hover
- Full image modal with metadata and recreate option
- XSS protection with input sanitization

Tech Stack:
- Pure HTML/CSS/JavaScript (no dependencies)
- IndexedDB for image persistence
- OpenRouter API integration

License: GPL-3.0
This commit is contained in:
Yusuf İpek
2026-01-20 22:16:37 +03:00
commit 45f09260f9
6 changed files with 2134 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
# Imagen - AI Image Generation Tool
A powerful client-side AI image generation tool using OpenRouter API. Generate thumbnails, artwork, and creative images with multiple state-of-the-art models.
![Imagen UI](UI.webp)
![Imagen UI-1](UI-1.webp)
## ✨ Features
### 🎨 Multi-Model Support
- **Gemini 2.5 Flash Image** - Google's fast image generation
- **Gemini 2.5 Flash (Preview)** - Preview version with latest features
- **Gemini 3 Pro (Preview)** - Advanced model, up to 14 reference images
- **GPT-5 Image** - OpenAI's latest image model
- **GPT-5 Image Mini** - Faster, smaller GPT-5 variant
- **Flux 2 Pro / Max / Flex / Klein** - Black Forest Labs models
- **Seedream 4.5** - ByteDance's image model
- **Riverflow V2** - Fast/Standard/Max variants
### 📐 Flexible Output Options
- **Resolution**: 1K, 2K, 4K (Gemini models)
- **Aspect Ratios**: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2
- **Batch Generation**: Up to 8 images at once
### 🖼️ Reference Image Support
- Upload unlimited reference images
- Drag & drop support
- Use generated images as references
- Click X to remove individual references
### 💾 Persistent Storage
- **IndexedDB storage**
- Store hundreds of images
- Images persist across browser sessions
### 🎯 Gallery Features
- View all generated images
- Delete individual images (hover to reveal 🗑️ button)
- Click any image for full view + metadata
- Clear entire gallery option
### ♻️ Recreate Feature
- Click any image to restore its original settings
- Instantly iterate on previous generations
## 🚀 Quick Start
1. Clone this repository
2. Start a local server:
```bash
python3 -m http.server 8080
# or
npx serve .
```
3. Open http://localhost:8080
4. Enter your OpenRouter API key
5. Write a prompt and click Generate!
## 🔑 Getting an OpenRouter API Key
1. Go to [OpenRouter](https://openrouter.ai/)
2. Create an account
3. Navigate to **Keys** section
4. Create a new API key
5. Copy and paste it into the tool
## 🔒 Privacy & Security
This is a **100% client-side application**:
- ✅ API keys are stored in YOUR browser only
- ✅ Generated images are stored in YOUR browser only (IndexedDB)
- ✅ No data is sent to any server except OpenRouter API
- ✅ Safe to deploy as a static website
## ⌨️ Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl + Enter` | Generate images |
| `Escape` | Close image modal |
## 🛠️ Tech Stack
- **Frontend**: Pure HTML/CSS/JavaScript (no dependencies)
- **API**: OpenRouter for model access
- **Storage**: IndexedDB for image persistence
- **Styling**: Custom CSS with CSS variables
## 📁 Project Structure
```
imagen/
├── index.html # Main UI structure
├── styles.css # Premium dark theme styling
├── app.js # Core logic + API integration
└── README.md # This file
```
## 📜 License
This project is licensed under the **GNU General Public License v3.0** (GPL-3.0).
You are free to:
- ✅ Use this software for any purpose
- ✅ Study how the software works and modify it
- ✅ Distribute copies of the software
- ✅ Distribute modified versions
Under the condition that:
- 📋 You include the original license and copyright notice
- 📋 You disclose the source code when distributing
- 📋 Modified versions must also be licensed under GPL-3.0
See the [LICENSE](LICENSE) file for full details.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+918
View File
@@ -0,0 +1,918 @@
/**
* Imagen - Internal AI Image Generation Tool
* Supports multiple models via OpenRouter API
*/
// ===== IndexedDB Storage =====
const ImagenDB = {
dbName: 'ImagenDB',
storeName: 'images',
db: null,
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
});
},
async saveImage(imageData) {
await this.ensureOpen();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(imageData);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
async getAllImages() {
await this.ensureOpen();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
// Sort by createdAt descending (newest first)
const images = request.result.sort((a, b) =>
new Date(b.createdAt) - new Date(a.createdAt)
);
resolve(images);
};
request.onerror = () => reject(request.error);
});
},
async deleteImage(id) {
await this.ensureOpen();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
},
async clearAll() {
await this.ensureOpen();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
},
async ensureOpen() {
if (!this.db) {
await this.open();
}
}
};
// ===== State Management =====
const state = {
apiKey: localStorage.getItem('imagen_api_key') || '',
selectedModel: localStorage.getItem('imagen_model') || 'google/gemini-2.5-flash-image',
imageSize: '1024x1024',
imageQuality: '1K',
aspectRatio: '1:1',
imageCount: 1,
references: [], // Dynamic array - unlimited references
images: [], // Will be loaded from IndexedDB
currentImage: null,
isGenerating: false
};
// ===== Model Configurations =====
const MODEL_CONFIGS = {
'google/gemini-2.5-flash-image': {
name: 'Gemini 2.5 Flash Image',
supportsImageSize: true,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 3
},
'google/gemini-2.5-flash-image-preview': {
name: 'Gemini 2.5 Flash Image (Preview)',
supportsImageSize: true,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 3
},
'google/gemini-3-pro-image-preview': {
name: 'Gemini 3 Pro Image (Preview)',
supportsImageSize: true,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 14
},
'openai/gpt-5-image': {
name: 'GPT-5 Image',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 1
},
'openai/gpt-5-image-mini': {
name: 'GPT-5 Image Mini',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 1
},
'black-forest-labs/flux.2-pro': {
name: 'Flux 2 Pro',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'black-forest-labs/flux.2-max': {
name: 'Flux 2 Max',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'black-forest-labs/flux.2-flex': {
name: 'Flux 2 Flex',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'black-forest-labs/flux.2-klein-4b': {
name: 'Flux 2 Klein 4B',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'bytedance-seed/seedream-4.5': {
name: 'Seedream 4.5',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'sourceful/riverflow-v2-fast-preview': {
name: 'Riverflow V2 Fast',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'sourceful/riverflow-v2-standard-preview': {
name: 'Riverflow V2 Standard',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'sourceful/riverflow-v2-max-preview': {
name: 'Riverflow V2 Max',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
}
};
// ===== DOM Elements =====
const elements = {
// Sidebar
modelSelectContainer: document.getElementById('modelSelectContainer'),
modelSelectTrigger: document.getElementById('modelSelectTrigger'),
modelSelectValue: document.getElementById('modelSelectValue'),
modelSelectOptions: document.getElementById('modelSelectOptions'),
geminiOptions: document.getElementById('geminiOptions'),
apiKey: document.getElementById('apiKey'),
saveApiKey: document.getElementById('saveApiKey'),
imageCount: document.getElementById('imageCount'),
decreaseCount: document.getElementById('decreaseCount'),
increaseCount: document.getElementById('increaseCount'),
clearReferences: document.getElementById('clearReferences'),
referenceSlots: document.getElementById('referenceSlots'),
// Main Content
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'),
// Modal
imageModal: document.getElementById('imageModal'),
modalOverlay: document.getElementById('modalOverlay'),
modalClose: document.getElementById('modalClose'),
modalImage: document.getElementById('modalImage'),
modalMetadata: document.getElementById('modalMetadata'),
useAsReference: document.getElementById('useAsReference'),
recreateImage: document.getElementById('recreateImage'),
downloadImage: document.getElementById('downloadImage')
};
// ===== Initialization =====
async function init() {
// Load saved API key
if (state.apiKey) {
elements.apiKey.value = state.apiKey;
}
// Render reference slots
renderReferenceSlots();
// Restore saved model selection
if (state.selectedModel) {
const savedOption = document.querySelector(`.custom-select-option[data-value="${state.selectedModel}"]`);
if (savedOption) {
document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected'));
savedOption.classList.add('selected');
elements.modelSelectValue.textContent = savedOption.textContent;
}
}
// Load images from IndexedDB
try {
state.images = await ImagenDB.getAllImages();
} catch (error) {
console.error('Failed to load images from IndexedDB:', error);
state.images = [];
}
// Render gallery
renderGallery();
// Set up event listeners
setupEventListeners();
// Initialize UI state
updateGeminiOptionsVisibility();
}
// ===== Event Listeners =====
function setupEventListeners() {
// Custom dropdown - toggle
elements.modelSelectTrigger.addEventListener('click', () => {
elements.modelSelectContainer.classList.toggle('open');
});
// Custom dropdown - option selection
document.querySelectorAll('.custom-select-option').forEach(option => {
option.addEventListener('click', () => {
state.selectedModel = option.dataset.value;
localStorage.setItem('imagen_model', state.selectedModel);
elements.modelSelectValue.textContent = option.textContent;
document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected'));
option.classList.add('selected');
elements.modelSelectContainer.classList.remove('open');
updateGeminiOptionsVisibility();
});
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!elements.modelSelectContainer.contains(e.target)) {
elements.modelSelectContainer.classList.remove('open');
}
});
// Size toggle buttons
document.querySelectorAll('.btn-toggle').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.btn-toggle').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.imageSize = btn.dataset.size;
state.imageQuality = btn.dataset.quality;
});
});
// Aspect ratio buttons
document.querySelectorAll('.btn-aspect').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.btn-aspect').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.aspectRatio = btn.dataset.ratio;
});
});
// Image count
if (elements.decreaseCount) {
elements.decreaseCount.addEventListener('click', () => {
if (state.imageCount > 1) {
state.imageCount--;
elements.imageCount.value = state.imageCount;
}
});
}
if (elements.increaseCount) {
elements.increaseCount.addEventListener('click', () => {
if (state.imageCount < 8) {
state.imageCount++;
elements.imageCount.value = state.imageCount;
}
});
}
if (elements.imageCount) {
elements.imageCount.addEventListener('change', (e) => {
let val = parseInt(e.target.value);
if (isNaN(val) || val < 1) val = 1;
if (val > 8) val = 8;
state.imageCount = val;
elements.imageCount.value = val;
});
}
// API Key
elements.saveApiKey.addEventListener('click', () => {
state.apiKey = elements.apiKey.value.trim();
localStorage.setItem('imagen_api_key', state.apiKey);
showToast('API key saved!', 'success');
});
// Reference images are handled by renderReferenceSlots()
elements.clearReferences.addEventListener('click', clearAllReferences);
// Drag & Drop for reference images
setupDragAndDrop();
// Prompt input
elements.promptInput.addEventListener('input', () => {
elements.charCount.textContent = `${elements.promptInput.value.length} chars`;
});
// Generate button
elements.generateBtn.addEventListener('click', generateImages);
// Clear gallery
elements.clearGallery.addEventListener('click', async () => {
if (confirm('Are you sure you want to clear all generated images?')) {
state.images = [];
try {
await ImagenDB.clearAll();
} catch (e) {
console.warn('Could not clear IndexedDB:', e);
}
renderGallery();
showToast('Gallery cleared', 'success');
}
});
// Modal
elements.modalOverlay.addEventListener('click', closeModal);
elements.modalClose.addEventListener('click', closeModal);
elements.useAsReference.addEventListener('click', useImageAsReference);
elements.recreateImage.addEventListener('click', recreateImage);
elements.downloadImage.addEventListener('click', downloadCurrentImage);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
if (e.key === 'Enter' && e.ctrlKey) generateImages();
});
}
// ===== Drag & Drop =====
function setupDragAndDrop() {
const dropZone = elements.referenceSlots;
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('drag-over');
}, false);
});
dropZone.addEventListener('drop', handleDrop, false);
}
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
[...files].forEach(file => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (event) => {
state.references.push(event.target.result);
renderReferenceSlots();
};
reader.readAsDataURL(file);
}
});
if (files.length > 0) {
showToast(`${files.length} image(s) added as reference`, 'success');
}
}
// ===== Reference Image Handling =====
function handleReferenceUpload(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
state.references.push(event.target.result);
renderReferenceSlots();
};
reader.readAsDataURL(file);
// Reset the input so the same file can be selected again
e.target.value = '';
}
function renderReferenceSlots() {
const container = document.getElementById('referenceSlots');
container.innerHTML = '';
// Render existing references
state.references.forEach((ref, index) => {
const slot = document.createElement('div');
slot.className = 'reference-slot filled';
slot.dataset.slot = index;
slot.innerHTML = `
<img src="${ref}" alt="Reference ${index + 1}">
<button class="remove-ref" data-index="${index}">×</button>
`;
container.appendChild(slot);
});
// Add "Add new" slot
const addSlot = document.createElement('div');
addSlot.className = 'reference-slot empty add-new';
addSlot.innerHTML = `
<span class="slot-label">+ Add</span>
<input type="file" accept="image/*" class="reference-input" id="addReferenceInput">
`;
container.appendChild(addSlot);
// Attach event listeners
container.querySelectorAll('.remove-ref').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const index = parseInt(btn.dataset.index);
removeReference(index);
});
});
const addInput = container.querySelector('#addReferenceInput');
if (addInput) {
addInput.addEventListener('change', handleReferenceUpload);
}
}
function removeReference(index) {
state.references.splice(index, 1);
renderReferenceSlots();
}
function clearAllReferences() {
state.references = [];
renderReferenceSlots();
showToast('References cleared', 'success');
}
// ===== Image Generation =====
async function generateImages() {
const prompt = elements.promptInput.value.trim();
if (!prompt) {
showToast('Please enter a prompt', 'warning');
return;
}
if (!state.apiKey) {
showToast('Please enter your OpenRouter API key', 'error');
return;
}
state.isGenerating = true;
elements.generateBtn.disabled = true;
elements.loadingContainer.style.display = 'flex';
try {
const modelConfig = MODEL_CONFIGS[state.selectedModel];
const promises = [];
for (let i = 0; i < state.imageCount; i++) {
promises.push(generateSingleImage(prompt, modelConfig));
}
const results = await Promise.allSettled(promises);
let successCount = 0;
const newImages = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
const imageData = {
id: Date.now() + index,
url: result.value,
prompt: prompt,
model: state.selectedModel,
modelName: modelConfig.name,
size: state.imageSize,
quality: state.imageQuality,
aspectRatio: state.aspectRatio,
createdAt: new Date().toISOString()
};
newImages.push(imageData);
state.images.unshift(imageData);
successCount++;
} else {
console.error('Failed to generate image:', result.reason);
}
});
if (successCount > 0) {
// Save to IndexedDB (no size limit!)
try {
for (const img of newImages) {
await ImagenDB.saveImage(img);
}
} catch (dbError) {
console.error('Failed to save to IndexedDB:', dbError);
}
renderGallery();
showToast(`${successCount} image(s) generated!`, 'success');
} else {
showToast('Failed to generate images. Check console for details.', 'error');
}
} catch (error) {
console.error('Generation error:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
state.isGenerating = false;
elements.generateBtn.disabled = false;
elements.loadingContainer.style.display = 'none';
}
}
async function generateSingleImage(prompt, modelConfig) {
// Build message content
const content = [];
// Add reference images if supported
if (modelConfig.supportsImageInput) {
state.references.forEach((ref, index) => {
if (ref) {
content.push({
type: 'image_url',
image_url: {
url: ref,
detail: 'high'
}
});
}
});
}
// Add text prompt
content.push({
type: 'text',
text: prompt
});
// Build request body
const requestBody = {
model: state.selectedModel,
messages: [
{
role: 'user',
content: content.length === 1 ? prompt : content
}
],
modalities: modelConfig.modalities
};
// Add Gemini-specific options
if (modelConfig.supportsImageSize && state.selectedModel.includes('gemini')) {
requestBody.image_config = {
image_size: state.imageQuality.toLowerCase(),
aspect_ratio: state.aspectRatio
};
}
// Add aspect ratio for other models
if (modelConfig.supportsAspectRatio && !state.selectedModel.includes('gemini')) {
requestBody.aspect_ratio = state.aspectRatio;
}
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${state.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'Imagen Internal Tool'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || `API error: ${response.status}`);
}
const data = await response.json();
// Extract image from response
// OpenRouter returns images in different formats depending on the model
const message = data.choices?.[0]?.message;
if (!message) {
throw new Error('No response from model');
}
// Log full response for debugging
console.log('API Response:', JSON.stringify(data, null, 2));
// Check for images array in message (OpenRouter SDK format)
// According to OpenRouter docs: message.images[].image_url.url
if (message.images && message.images.length > 0) {
const img = message.images[0];
// OpenRouter SDK format: { image_url: { url: "data:image/..." } }
if (img.image_url?.url) {
return img.image_url.url;
}
// Alternative formats
if (typeof img === 'string') {
if (img.startsWith('data:') || img.startsWith('http')) {
return img;
}
return `data:image/png;base64,${img}`;
}
if (img.url) return img.url;
if (img.b64_json) return `data:image/png;base64,${img.b64_json}`;
}
// Check for image in content parts (different models may use this format)
if (Array.isArray(message.content)) {
for (const part of message.content) {
// OpenAI-style image_url part
if (part.type === 'image_url' && part.image_url?.url) {
return part.image_url.url;
}
// Gemini-style inlineData part
if (part.inlineData?.data) {
const mimeType = part.inlineData.mimeType || 'image/png';
return `data:${mimeType};base64,${part.inlineData.data}`;
}
// Generic image part
if (part.type === 'image' && part.image) {
if (part.image.startsWith('data:')) {
return part.image;
}
return `data:image/png;base64,${part.image}`;
}
}
}
// Check if content itself is the image data (some models return this way)
if (typeof message.content === 'string' && message.content.startsWith('data:image')) {
return message.content;
}
throw new Error('No image in response. Check console for full API response.');
}
// ===== Gallery =====
function renderGallery() {
if (state.images.length === 0) {
elements.galleryEmpty.style.display = 'flex';
elements.gallery.innerHTML = '';
elements.gallery.appendChild(elements.galleryEmpty);
return;
}
elements.gallery.innerHTML = '';
state.images.forEach((image, index) => {
const card = document.createElement('div');
card.className = 'image-card';
// Sanitize URL - only allow data URIs and https URLs
const safeUrl = sanitizeImageUrl(image.url);
const safePrompt = escapeHtml(image.prompt);
card.innerHTML = `
<button class="image-card-delete" data-index="${index}" title="Delete image">🗑️</button>
<img src="${safeUrl}" alt="${safePrompt}" loading="lazy">
<div class="image-card-overlay">
<p class="image-card-prompt">${safePrompt}</p>
<div class="image-card-meta">
<span class="meta-tag">${escapeHtml(image.modelName || image.model)}</span>
<span class="meta-tag">${escapeHtml(image.quality || image.size)}</span>
<span class="meta-tag">${escapeHtml(image.aspectRatio)}</span>
</div>
</div>
`;
// Delete button handler
const deleteBtn = card.querySelector('.image-card-delete');
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
deleteImage(index);
});
// Open modal on card click
card.addEventListener('click', () => openModal(image));
elements.gallery.appendChild(card);
});
}
async function deleteImage(index) {
const imageToDelete = state.images[index];
state.images.splice(index, 1);
try {
await ImagenDB.deleteImage(imageToDelete.id);
} catch (e) {
console.warn('Could not delete from IndexedDB:', e);
}
renderGallery();
showToast('Image deleted', 'success');
}
// ===== Modal =====
function openModal(image) {
state.currentImage = image;
elements.modalImage.src = sanitizeImageUrl(image.url);
elements.modalMetadata.innerHTML = `
<p><strong>Prompt:</strong> ${escapeHtml(image.prompt)}</p>
<p><strong>Model:</strong> ${escapeHtml(image.modelName || image.model)}</p>
<p><strong>Size/Quality:</strong> ${escapeHtml(image.quality || image.size)}</p>
<p><strong>Aspect Ratio:</strong> ${escapeHtml(image.aspectRatio)}</p>
<p><strong>Created:</strong> ${escapeHtml(new Date(image.createdAt).toLocaleString())}</p>
${image.references?.length > 0 ? `<p><strong>References Used:</strong> ${escapeHtml(image.references.length)}</p>` : ''}
`;
elements.imageModal.classList.add('active');
}
function closeModal() {
elements.imageModal.classList.remove('active');
state.currentImage = null;
}
function useImageAsReference() {
if (!state.currentImage) return;
state.references.push(state.currentImage.url);
renderReferenceSlots();
closeModal();
showToast('Image added as reference', 'success');
}
function recreateImage() {
if (!state.currentImage) return;
// Restore prompt
elements.promptInput.value = state.currentImage.prompt;
elements.charCount.textContent = `${state.currentImage.prompt.length} chars`;
// Restore model
elements.modelSelect.value = state.currentImage.model;
state.selectedModel = state.currentImage.model;
updateGeminiOptionsVisibility();
// Restore quality/size
document.querySelectorAll('.btn-toggle').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.quality === state.currentImage.quality) {
btn.classList.add('active');
state.imageSize = btn.dataset.size;
state.imageQuality = btn.dataset.quality;
}
});
// Restore aspect ratio
document.querySelectorAll('.btn-aspect').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.ratio === state.currentImage.aspectRatio) {
btn.classList.add('active');
state.aspectRatio = state.currentImage.aspectRatio;
}
});
// Restore references
if (state.currentImage.references) {
state.references = [null, null, null];
state.currentImage.references.forEach((ref, index) => {
if (index < 3) {
state.references[index] = ref;
updateReferenceSlot(index);
}
});
}
closeModal();
showToast('Settings restored. Click Generate to recreate.', 'success');
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function downloadCurrentImage() {
if (!state.currentImage) return;
const link = document.createElement('a');
link.href = state.currentImage.url;
link.download = `imagen_${state.currentImage.id}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showToast('Download started', 'success');
}
// ===== UI Helpers =====
function updateGeminiOptionsVisibility() {
const isGemini = state.selectedModel.includes('gemini');
elements.geminiOptions.style.display = isGemini ? 'flex' : 'none';
}
function showToast(message, type = 'info') {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
function escapeHtml(text) {
if (text == null) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
function sanitizeImageUrl(url) {
if (!url) return '';
// Only allow data URIs and HTTPS URLs
if (url.startsWith('data:image/')) {
return url;
}
if (url.startsWith('https://')) {
// Escape any potential attribute-breaking characters
return url.replace(/"/g, '%22').replace(/'/g, '%27');
}
// Block everything else (http, javascript:, etc.)
console.warn('Blocked unsafe image URL:', url);
return '';
}
// ===== Global functions for inline handlers =====
window.removeReference = removeReference;
// ===== Initialize =====
document.addEventListener('DOMContentLoaded', init);
+167
View File
@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Imagen</title>
<link rel="stylesheet" href="styles.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h1 class="logo">🎨 Imagen</h1>
</div>
<!-- Reference Images Section -->
<div class="reference-section">
<h3>Reference Images</h3>
<div class="reference-slots" id="referenceSlots">
<!-- Slots rendered dynamically by JS -->
</div>
<button class="btn btn-ghost" id="clearReferences">Clear All References</button>
</div>
<!-- Model Selection -->
<div class="config-section">
<h3>Model</h3>
<div class="custom-select" id="modelSelectContainer">
<div class="custom-select-trigger" id="modelSelectTrigger">
<span id="modelSelectValue">Gemini 2.5 Flash Image</span>
<span class="custom-select-arrow"></span>
</div>
<div class="custom-select-options" id="modelSelectOptions">
<div class="custom-select-option selected" data-value="google/gemini-2.5-flash-image">Gemini 2.5
Flash Image</div>
<div class="custom-select-option" data-value="google/gemini-2.5-flash-image-preview">Gemini 2.5
Flash (Preview)</div>
<div class="custom-select-option" data-value="google/gemini-3-pro-image-preview">Gemini 3 Pro
(Preview)</div>
<div class="custom-select-option" data-value="openai/gpt-5-image">GPT-5 Image</div>
<div class="custom-select-option" data-value="openai/gpt-5-image-mini">GPT-5 Image Mini</div>
<div class="custom-select-option" data-value="black-forest-labs/flux.2-pro">Flux 2 Pro</div>
<div class="custom-select-option" data-value="black-forest-labs/flux.2-max">Flux 2 Max</div>
<div class="custom-select-option" data-value="black-forest-labs/flux.2-flex">Flux 2 Flex</div>
<div class="custom-select-option" data-value="black-forest-labs/flux.2-klein-4b">Flux 2 Klein 4B
</div>
<div class="custom-select-option" data-value="bytedance-seed/seedream-4.5">Seedream 4.5</div>
<div class="custom-select-option" data-value="sourceful/riverflow-v2-fast-preview">Riverflow V2
Fast</div>
<div class="custom-select-option" data-value="sourceful/riverflow-v2-standard-preview">Riverflow
V2 Standard</div>
<div class="custom-select-option" data-value="sourceful/riverflow-v2-max-preview">Riverflow V2
Max</div>
</div>
</div>
</div>
<!-- Image Quality (Gemini specific) -->
<div class="config-section" id="geminiOptions">
<h3>Image Size</h3>
<div class="button-group">
<button class="btn-toggle active" data-size="1024x1024" data-quality="1K">1K</button>
<button class="btn-toggle" data-size="2048x2048" data-quality="2K">2K</button>
<button class="btn-toggle" data-size="4096x4096" data-quality="4K">4K</button>
</div>
</div>
<!-- Aspect Ratio -->
<div class="config-section">
<h3>Aspect Ratio</h3>
<div class="aspect-grid">
<button class="btn-aspect active" data-ratio="1:1">1:1</button>
<button class="btn-aspect" data-ratio="16:9">16:9</button>
<button class="btn-aspect" data-ratio="9:16">9:16</button>
<button class="btn-aspect" data-ratio="4:3">4:3</button>
<button class="btn-aspect" data-ratio="3:4">3:4</button>
<button class="btn-aspect" data-ratio="3:2">3:2</button>
</div>
</div>
<!-- Number of Images -->
<div class="config-section">
<h3>Number of Images</h3>
<div class="number-input-group">
<button type="button" class="btn-number" id="decreaseCount"></button>
<input type="number" id="imageCount" value="1" min="1" max="8" class="number-input">
<button type="button" class="btn-number" id="increaseCount">+</button>
</div>
</div>
<!-- API Key -->
<div class="config-section">
<h3>OpenRouter API Key</h3>
<input type="password" id="apiKey" placeholder="sk-or-..." class="text-input">
<button class="btn btn-ghost" id="saveApiKey">Save Key</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<!-- Prompt Area -->
<div class="prompt-area">
<div class="prompt-container">
<textarea id="promptInput" placeholder="Describe the image you want to generate..."
rows="3"></textarea>
<div class="prompt-actions">
<span class="char-count" id="charCount">0 chars</span>
<button class="btn btn-primary" id="generateBtn">
Generate
</button>
</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>
<!-- Gallery -->
<div class="gallery-header">
<h2>Generated Images</h2>
<div class="gallery-actions">
<button class="btn btn-ghost" id="clearGallery">Clear Gallery</button>
</div>
</div>
<div class="gallery" id="gallery">
<div class="gallery-empty" id="galleryEmpty">
<span class="empty-icon">🖼️</span>
<p>No images generated yet</p>
<p class="empty-hint">Write a prompt above and click Generate</p>
</div>
</div>
</main>
</div>
<!-- Image Modal -->
<div class="modal" id="imageModal">
<div class="modal-overlay" id="modalOverlay"></div>
<div class="modal-content">
<button class="modal-close" id="modalClose">×</button>
<img src="" alt="Full size image" id="modalImage">
<div class="modal-actions">
<button class="btn btn-secondary" id="useAsReference">
<span>📌</span> Use as Reference
</button>
<button class="btn btn-secondary" id="recreateImage">
<span>🔄</span> Recreate
</button>
<button class="btn btn-secondary" id="downloadImage">
<span>⬇️</span> Download
</button>
</div>
<div class="modal-metadata" id="modalMetadata">
<!-- Metadata will be injected here -->
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+934
View File
@@ -0,0 +1,934 @@
/* ===== CSS Variables ===== */
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #111111;
--bg-tertiary: #1a1a1a;
--bg-card: #141414;
--bg-hover: #222222;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #666666;
--accent-primary: #ffffff;
--accent-secondary: #cccccc;
--accent-glow: rgba(255, 255, 255, 0.15);
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--border-color: rgba(255, 255, 255, 0.1);
--border-radius: 12px;
--border-radius-sm: 8px;
--border-radius-lg: 16px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 400ms ease;
--sidebar-width: 320px;
}
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
/* ===== App Container ===== */
.app-container {
display: flex;
min-height: 100vh;
}
/* ===== Sidebar ===== */
.sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
overflow-y: auto;
position: fixed;
top: 0;
left: 0;
height: 100vh;
}
.sidebar-header {
text-align: center;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.logo {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
}
.logo-subtitle {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 2px;
margin-top: 4px;
}
/* ===== Reference Section ===== */
.reference-section h3,
.config-section h3 {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.reference-slots {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
min-height: 70px;
padding: 8px;
border-radius: var(--border-radius-sm);
border: 2px dashed transparent;
transition: all var(--transition-fast);
}
.reference-slots.drag-over {
background: var(--accent-glow);
border-color: var(--accent-primary);
}
.reference-count {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 8px;
display: block;
}
.reference-slot {
width: 80px;
height: 80px;
border-radius: var(--border-radius-sm);
border: 2px dashed var(--border-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all var(--transition-fast);
background: var(--bg-tertiary);
flex-shrink: 0;
}
.reference-slot.add-new {
border-style: dashed;
}
.reference-slot:hover {
border-color: var(--accent-primary);
background: var(--bg-hover);
}
.reference-slot.empty .slot-label {
font-size: 0.6rem;
color: var(--text-muted);
text-align: center;
padding: 2px;
}
.reference-slot.filled {
border-style: solid;
border-color: var(--accent-primary);
}
.reference-slot img {
width: 100%;
height: 100%;
object-fit: cover;
}
.reference-slot .remove-ref {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--error);
color: white;
border: none;
cursor: pointer;
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.filled:hover .remove-ref {
opacity: 1;
}
.reference-input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
/* ===== Config Sections ===== */
.config-section {
display: flex;
flex-direction: column;
}
/* ===== Custom Select Dropdown ===== */
.custom-select {
position: relative;
width: 100%;
}
.custom-select-trigger {
width: 100%;
padding: 12px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: space-between;
}
.custom-select-trigger:hover {
border-color: var(--accent-primary);
background-color: var(--bg-hover);
}
.custom-select.open .custom-select-trigger {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.custom-select-arrow {
color: var(--accent-primary);
font-size: 0.8rem;
transition: transform var(--transition-fast);
}
.custom-select.open .custom-select-arrow {
transform: rotate(180deg);
}
.custom-select-options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: var(--shadow-lg);
z-index: 100;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: all var(--transition-fast);
}
.custom-select.open .custom-select-options {
max-height: 300px;
opacity: 1;
overflow-y: auto;
}
.custom-select-option {
padding: 12px 16px;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
font-size: 0.9rem;
}
.custom-select-option:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.custom-select-option.selected {
background: var(--accent-primary);
color: #000000;
}
/* ===== Button Toggle Group ===== */
.button-group {
display: flex;
gap: 8px;
}
.btn-toggle {
flex: 1;
padding: 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-toggle.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: #000000;
}
/* ===== Aspect Ratio Grid ===== */
.aspect-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.btn-aspect {
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-aspect:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-aspect.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: #000000;
}
/* ===== Number Input ===== */
.number-input-group {
display: flex;
align-items: center;
gap: 8px;
}
.btn-number {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 1.25rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-number:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.number-input {
flex: 1;
text-align: center;
padding: 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
-moz-appearance: textfield;
appearance: textfield;
}
/* Hide number input spinners */
.number-input::-webkit-outer-spin-button,
.number-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.number-input:focus {
outline: none;
border-color: var(--accent-primary);
}
/* ===== Text Input ===== */
.text-input {
width: 100%;
padding: 12px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
transition: all var(--transition-fast);
margin-bottom: 8px;
}
.text-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.text-input::placeholder {
color: var(--text-muted);
}
/* ===== Buttons ===== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border-radius: var(--border-radius-sm);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
border: none;
}
.btn-primary {
background: var(--accent-primary);
color: #000000;
box-shadow: 0 4px 12px var(--accent-glow);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--accent-glow);
background: var(--accent-secondary);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 8px 16px;
font-size: 0.8rem;
}
.btn-ghost:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ===== Main Content ===== */
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* ===== Prompt Area ===== */
.prompt-area {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-primary);
padding-bottom: 16px;
}
.prompt-container {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
padding: 16px;
box-shadow: var(--shadow-md);
}
.prompt-container textarea {
width: 100%;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 1rem;
font-family: inherit;
resize: none;
min-height: 80px;
}
.prompt-container textarea:focus {
outline: none;
}
.prompt-container textarea::placeholder {
color: var(--text-muted);
}
.prompt-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.char-count {
font-size: 0.8rem;
color: var(--text-muted);
}
.btn-icon {
font-size: 1rem;
}
/* ===== Loading ===== */
.loading-container {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
background: var(--bg-secondary);
border-radius: var(--border-radius);
margin-top: 16px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
color: var(--text-secondary);
font-size: 0.9rem;
}
/* ===== Gallery ===== */
.gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.gallery-header h2 {
font-size: 1.25rem;
font-weight: 600;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
flex: 1;
align-items: start;
}
.gallery-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background: var(--bg-secondary);
border-radius: var(--border-radius-lg);
border: 2px dashed var(--border-color);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 16px;
opacity: 0.5;
}
.gallery-empty p {
color: var(--text-secondary);
font-size: 1rem;
}
.gallery-empty .empty-hint {
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 4px;
}
/* ===== Image Card ===== */
.image-card {
background: var(--bg-card);
border-radius: var(--border-radius);
overflow: hidden;
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
cursor: pointer;
position: relative;
}
.image-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--accent-primary);
}
.image-card img {
width: 100%;
height: auto;
display: block;
}
.image-card-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 50%);
opacity: 0;
transition: opacity var(--transition-fast);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 16px;
}
.image-card:hover .image-card-overlay {
opacity: 1;
}
.image-card-delete {
position: absolute;
top: 10px;
right: 10px;
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 {
opacity: 1;
}
.image-card-delete:hover {
background: var(--error);
border-color: var(--error);
transform: scale(1.1);
}
.image-card-prompt {
font-size: 0.8rem;
color: var(--text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.image-card-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.meta-tag {
font-size: 0.7rem;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: #fff;
backdrop-filter: blur(4px);
}
/* ===== Modal ===== */
.modal {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all var(--transition-normal);
}
.modal.active {
opacity: 1;
visibility: visible;
}
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
}
.modal-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
background: var(--bg-secondary);
border-radius: var(--border-radius-lg);
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
transform: scale(0.95);
transition: transform var(--transition-normal);
}
.modal.active .modal-content {
transform: scale(1);
}
.modal-close {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
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;
justify-content: center;
transition: all var(--transition-fast);
z-index: 10;
}
.modal-close:hover {
background: var(--error);
}
#modalImage {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: var(--border-radius);
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.modal-metadata {
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
padding: 16px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.modal-metadata p {
margin-bottom: 8px;
}
.modal-metadata p:last-child {
margin-bottom: 0;
}
.modal-metadata strong {
color: var(--text-primary);
}
/* ===== Scrollbar ===== */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #444;
}
/* ===== Toast Notifications ===== */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
padding: 16px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: var(--shadow-lg);
animation: slideIn 0.3s ease;
display: flex;
align-items: center;
gap: 12px;
}
.toast.success {
border-left: 4px solid var(--success);
}
.toast.error {
border-left: 4px solid var(--error);
}
.toast.warning {
border-left: 4px solid var(--warning);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* ===== Responsive ===== */
@media (max-width: 1024px) {
.sidebar {
width: 280px;
}
.main-content {
margin-left: 280px;
}
}
@media (max-width: 768px) {
.app-container {
flex-direction: column;
}
.sidebar {
position: relative;
width: 100%;
height: auto;
padding: 16px;
}
.main-content {
margin-left: 0;
}
.reference-slots {
grid-template-columns: repeat(3, 1fr);
}
.gallery {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}