mirror of
https://github.com/yusufipk/imagen-openrouter.git
synced 2026-09-11 18:46:07 +00:00
refactor: restructure project layout
- Move source files (app.js, styles.css) to src/ directory - Move assets (UI.webp, UI-1.webp) to assets/ directory - Update references in index.html and README.md"
This commit is contained in:
+918
@@ -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);
|
||||
+934
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user