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
+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);