feat(gallery): Add auto tag feature for images

This commit is contained in:
Vito Sansevero
2025-12-07 17:25:26 -08:00
parent ba36814894
commit ad5f03d475
+645 -4
View File
@@ -183,10 +183,14 @@
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors">
☰ List View
</button>
<button id="settingsBtn"
<button id="settingsBtn"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors">
⚙️ Settings
</button>
<button id="autoTagBtn"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition-colors">
🏷️ Auto Tag
</button>
</div>
</div>
</div>
@@ -553,6 +557,243 @@
</div>
</div>
<!-- Auto Tag Modal -->
<div id="autoTagModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 border border-gray-700">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">🏷️ Auto Tag Images</h3>
<button onclick="hideModal('autoTagModal')" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Model Selection -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-3">Model Selection</label>
<div class="space-y-3">
<label class="flex items-center justify-between p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<div class="flex items-center">
<input type="radio" name="autoTagModel" value="gguf" checked class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">GGUF Model</span>
<span class="text-gray-400 text-sm ml-2">(Recommended, ~4.5GB)</span>
</div>
</div>
<div id="ggufModelStatus" class="flex items-center space-x-2">
<span class="text-gray-400 text-sm">Checking...</span>
</div>
</label>
<label class="flex items-center justify-between p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<div class="flex items-center">
<input type="radio" name="autoTagModel" value="hf" class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">HuggingFace Model</span>
<span class="text-gray-400 text-sm ml-2">(~16GB)</span>
</div>
</div>
<div id="hfModelStatus" class="flex items-center space-x-2">
<span class="text-gray-400 text-sm">Checking...</span>
</div>
</label>
</div>
</div>
<!-- Prompt Template -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-2">Tag Generation Prompt</label>
<textarea id="autoTagPrompt" rows="6"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-gray-100 text-sm font-mono resize-none custom-scrollbar"
placeholder="Enter prompt template...">Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).
Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.
Prioritize visual accuracy.
No sentences, no commentary, no captions. Only tags. Keep length short but descriptive.</textarea>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3">
<button onclick="hideModal('autoTagModal')"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="startReviewBtn"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
👁️ Review
</button>
<button id="startAutoTagBtn"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors">
🏷️ Auto Tag
</button>
</div>
</div>
</div>
<!-- Auto Tag Review Modal -->
<div id="autoTagReviewModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-4xl w-full mx-4 border border-gray-700">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">
👁️ Review Tags
<span class="text-gray-400 text-base font-normal">(<span id="reviewCurrentIndex">1</span> of <span id="reviewTotalCount">0</span>)</span>
</h3>
<button onclick="window.gallery.cancelReview()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Image Preview -->
<div class="bg-gray-900 rounded-lg p-4">
<img id="reviewImage" src="" alt="Review image" class="w-full h-64 object-contain rounded">
<p id="reviewImageName" class="text-gray-400 text-sm mt-2 text-center truncate">image_name.png</p>
</div>
<!-- Generated Tags -->
<div>
<label class="block text-sm font-medium text-gray-400 mb-3">Generated Tags (click to remove)</label>
<div id="reviewTagsContainer" class="flex flex-wrap gap-2 p-4 bg-gray-900 rounded-lg min-h-[200px] max-h-[250px] overflow-y-auto custom-scrollbar">
<!-- Tag chips will be inserted here -->
</div>
<p class="text-gray-500 text-xs mt-2">Click the × on tags to remove them before applying</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-between mt-6">
<button id="skipReviewBtn"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
⏭️ Skip
</button>
<div class="flex space-x-3">
<button onclick="window.gallery.cancelReview()"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel All
</button>
<button id="applyReviewBtn"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
✅ Apply & Next
</button>
</div>
</div>
</div>
</div>
<!-- Auto Tag Progress Modal -->
<div id="autoTagProgressModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">🏷️ Auto Tagging Progress</h3>
<!-- Current File -->
<div class="mb-4">
<p class="text-gray-400 text-sm">Processing:</p>
<p id="autoTagCurrentFile" class="text-gray-100 font-medium truncate">Initializing...</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm text-gray-400 mb-2">
<span>Progress</span>
<span id="autoTagProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div id="autoTagProgressBar" class="bg-purple-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-purple-400" id="autoTagProcessed">0</div>
<div class="text-xs text-gray-400">Processed</div>
</div>
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-green-400" id="autoTagApplied">0</div>
<div class="text-xs text-gray-400">Tags Applied</div>
</div>
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-yellow-400" id="autoTagSkipped">0</div>
<div class="text-xs text-gray-400">Skipped</div>
</div>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelAutoTagBtn"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
<!-- Auto Tag Download Modal -->
<div id="autoTagDownloadModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">📥 Downloading Model</h3>
<!-- Model Name -->
<div class="mb-4">
<p class="text-gray-400 text-sm">Downloading:</p>
<p id="downloadModelName" class="text-gray-100 font-medium">GGUF Model</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm text-gray-400 mb-2">
<span id="downloadStatus">Preparing...</span>
<span id="downloadProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div id="downloadProgressBar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelDownloadBtn"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
<style>
/* Tag chip styles for Auto Tag feature */
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
background-color: #374151;
border: 1px solid #4B5563;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #D1D5DB;
transition: all 0.2s;
}
.tag-chip:hover {
background-color: #4B5563;
}
.tag-chip .tag-remove {
margin-left: 0.375rem;
padding: 0.125rem;
border-radius: 0.25rem;
cursor: pointer;
color: #9CA3AF;
transition: all 0.2s;
}
.tag-chip .tag-remove:hover {
color: #EF4444;
background-color: rgba(239, 68, 68, 0.2);
}
.hover\:bg-gray-650:hover {
background-color: #525252;
}
</style>
<script>
class GalleryManager {
constructor() {
@@ -576,6 +817,7 @@
document.getElementById('gridViewBtn').addEventListener('click', () => this.setViewMode('grid'));
document.getElementById('listViewBtn').addEventListener('click', () => this.setViewMode('list'));
document.getElementById('settingsBtn').addEventListener('click', () => this.showSettings());
document.getElementById('autoTagBtn').addEventListener('click', () => this.showAutoTagModal());
document.getElementById('retryBtn').addEventListener('click', () => this.loadImages());
document.getElementById('prevPageBtn').addEventListener('click', () => this.previousPage());
document.getElementById('nextPageBtn').addEventListener('click', () => this.nextPage());
@@ -612,6 +854,23 @@
this.hideDuplicatesModal();
}
});
// Auto Tag modal event listeners
document.getElementById('startAutoTagBtn').addEventListener('click', () => this.startAutoTag());
document.getElementById('startReviewBtn').addEventListener('click', () => this.startReview());
document.getElementById('skipReviewBtn').addEventListener('click', () => this.skipReviewImage());
document.getElementById('applyReviewBtn').addEventListener('click', () => this.applyReviewTags());
document.getElementById('cancelAutoTagBtn').addEventListener('click', () => this.cancelAutoTag());
document.getElementById('cancelDownloadBtn').addEventListener('click', () => this.cancelDownload());
// Close AutoTag modals on outside click
['autoTagModal', 'autoTagReviewModal', 'autoTagProgressModal', 'autoTagDownloadModal'].forEach(modalId => {
document.getElementById(modalId).addEventListener('click', (e) => {
if (e.target.id === modalId) {
this.hideModal(modalId);
}
});
});
}
async loadImages() {
@@ -3273,22 +3532,404 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
addStatusMessage('Connection to server lost', 'error');
progressBar.classList.add('bg-red-600');
progressBar.classList.remove('bg-violet-600');
// Clean up
eventSource.close();
if (!resultData) {
reject(new Error('Connection to server lost during thumbnail generation'));
}
};
});
}
// ==========================================
// Auto Tag Feature Methods
// ==========================================
// State for AutoTag feature
autoTagState = {
eventSource: null,
downloadEventSource: null,
reviewImages: [],
reviewIndex: 0,
currentTags: [],
cancelled: false
};
showModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
}
hideModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
document.body.style.overflow = '';
}
async showAutoTagModal() {
this.showModal("autoTagModal");
await this.checkAutoTagModels();
}
async checkAutoTagModels() {
const ggufStatus = document.getElementById("ggufModelStatus");
const hfStatus = document.getElementById("hfModelStatus");
ggufStatus.innerHTML = '<span class="text-gray-400 text-sm">Checking...</span>';
hfStatus.innerHTML = '<span class="text-gray-400 text-sm">Checking...</span>';
try {
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success) {
// Update GGUF status
if (data.models.gguf.downloaded) {
ggufStatus.innerHTML = '<span class="text-green-400 text-sm">✓ Downloaded</span>';
} else {
ggufStatus.innerHTML = `<button onclick="window.gallery.downloadModel('gguf')" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">Download</button>`;
}
// Update HF status
if (data.models.hf.downloaded) {
hfStatus.innerHTML = '<span class="text-green-400 text-sm">✓ Downloaded</span>';
} else {
hfStatus.innerHTML = `<button onclick="window.gallery.downloadModel('hf')" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">Download</button>`;
}
} else {
ggufStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
hfStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
}
} catch (error) {
console.error('Error checking models:', error);
ggufStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
hfStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
}
}
async downloadModel(modelType) {
const modelName = modelType === 'gguf' ? 'GGUF Model' : 'HuggingFace Model';
document.getElementById('downloadModelName').textContent = modelName;
document.getElementById('downloadStatus').textContent = 'Preparing...';
document.getElementById('downloadProgressPercent').textContent = '0%';
document.getElementById('downloadProgressBar').style.width = '0%';
this.hideModal("autoTagModal");
this.showModal("autoTagDownloadModal");
try {
this.autoTagState.downloadEventSource = new EventSource(`/prompt_manager/autotag/download/${modelType}`);
this.autoTagState.downloadEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'downloading') {
const percent = Math.round(data.progress * 100);
document.getElementById('downloadStatus').textContent = data.message || 'Downloading...';
document.getElementById('downloadProgressPercent').textContent = `${percent}%`;
document.getElementById('downloadProgressBar').style.width = `${percent}%`;
} else if (data.status === 'complete') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.checkAutoTagModels();
this.showNotification('Model downloaded successfully!', 'success');
} else if (data.status === 'error') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification(`Download failed: ${data.message}`, 'error');
}
};
this.autoTagState.downloadEventSource.onerror = () => {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Download connection lost', 'error');
};
} catch (error) {
console.error('Download error:', error);
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Failed to start download', 'error');
}
}
cancelDownload() {
if (this.autoTagState.downloadEventSource) {
this.autoTagState.downloadEventSource.close();
this.autoTagState.downloadEventSource = null;
}
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
}
async startAutoTag() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
const prompt = document.getElementById('autoTagPrompt').value;
// Check if model is downloaded
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
this.showNotification('Please download the selected model first', 'warning');
return;
}
this.hideModal("autoTagModal");
this.showModal("autoTagProgressModal");
// Reset progress display
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
document.getElementById('autoTagProgressPercent').textContent = '0%';
document.getElementById('autoTagProgressBar').style.width = '0%';
document.getElementById('autoTagProcessed').textContent = '0';
document.getElementById('autoTagApplied').textContent = '0';
document.getElementById('autoTagSkipped').textContent = '0';
this.autoTagState.cancelled = false;
try {
const formData = new URLSearchParams();
formData.append('model_type', modelType);
formData.append('prompt', prompt);
this.autoTagState.eventSource = new EventSource(`/prompt_manager/autotag/start?${formData.toString()}`);
this.autoTagState.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'loading_model') {
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
} else if (data.status === 'processing') {
const percent = Math.round((data.processed / data.total) * 100);
document.getElementById('autoTagCurrentFile').textContent = data.current_file || 'Processing...';
document.getElementById('autoTagProgressPercent').textContent = `${percent}%`;
document.getElementById('autoTagProgressBar').style.width = `${percent}%`;
document.getElementById('autoTagProcessed').textContent = data.processed;
document.getElementById('autoTagApplied').textContent = data.tags_applied;
document.getElementById('autoTagSkipped').textContent = data.skipped;
} else if (data.status === 'complete') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tagging complete! Applied tags to ${data.tags_applied} prompts.`, 'success');
this.loadImages(); // Refresh the gallery
} else if (data.status === 'error') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tag error: ${data.message}`, 'error');
} else if (data.status === 'cancelled') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification('Auto tagging cancelled', 'info');
}
};
this.autoTagState.eventSource.onerror = () => {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification('Connection lost during auto tagging', 'error');
};
} catch (error) {
console.error('Auto tag error:', error);
this.hideModal("autoTagProgressModal");
this.showNotification('Failed to start auto tagging', 'error');
}
}
cancelAutoTag() {
this.autoTagState.cancelled = true;
if (this.autoTagState.eventSource) {
this.autoTagState.eventSource.close();
this.autoTagState.eventSource = null;
}
this.hideModal("autoTagProgressModal");
this.showNotification('Auto tagging cancelled', 'info');
}
async startReview() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
// Check if model is downloaded
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
this.showNotification('Please download the selected model first', 'warning');
return;
}
this.hideModal("autoTagModal");
// Use current gallery images for review
if (!this.images || this.images.length === 0) {
this.showNotification('No images found in gallery', 'warning');
return;
}
this.autoTagState.reviewImages = this.images;
this.autoTagState.reviewIndex = 0;
this.autoTagState.modelType = modelType;
this.autoTagState.prompt = document.getElementById('autoTagPrompt').value;
document.getElementById('reviewTotalCount').textContent = this.images.length;
this.showModal("autoTagReviewModal");
await this.loadNextReviewImage();
}
async loadNextReviewImage() {
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
this.hideModal("autoTagReviewModal");
this.showNotification('Review complete!', 'success');
this.loadImages();
return;
}
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
document.getElementById('reviewCurrentIndex').textContent = this.autoTagState.reviewIndex + 1;
document.getElementById('reviewImage').src = image.thumbnail_url || image.url;
document.getElementById('reviewImageName').textContent = image.filename || 'Unknown';
document.getElementById('reviewTagsContainer').innerHTML = '<div class="text-gray-400">Generating tags...</div>';
try {
const response = await fetch('/prompt_manager/autotag/single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_path: image.path,
model_type: this.autoTagState.modelType,
prompt: this.autoTagState.prompt
})
});
const data = await response.json();
if (data.success) {
this.autoTagState.currentTags = data.tags;
this.renderReviewTags();
} else {
document.getElementById('reviewTagsContainer').innerHTML =
`<div class="text-red-400">Error: ${data.error}</div>`;
}
} catch (error) {
console.error('Error generating tags:', error);
document.getElementById('reviewTagsContainer').innerHTML =
'<div class="text-red-400">Failed to generate tags</div>';
}
}
renderReviewTags() {
const container = document.getElementById('reviewTagsContainer');
if (this.autoTagState.currentTags.length === 0) {
container.innerHTML = '<div class="text-gray-400">No tags generated</div>';
return;
}
container.innerHTML = this.autoTagState.currentTags.map((tag, index) => `
<span class="tag-chip">
${this.escapeHtml(tag)}
<span class="tag-remove" onclick="window.gallery.removeReviewTag(${index})">×</span>
</span>
`).join('');
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
removeReviewTag(index) {
this.autoTagState.currentTags.splice(index, 1);
this.renderReviewTags();
}
skipReviewImage() {
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
async applyReviewTags() {
if (this.autoTagState.currentTags.length === 0) {
this.skipReviewImage();
return;
}
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
try {
const response = await fetch('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_path: image.path,
tags: this.autoTagState.currentTags
})
});
const data = await response.json();
if (data.success) {
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
} else {
this.showNotification(data.error || 'Failed to apply tags', 'warning');
}
} catch (error) {
console.error('Error applying tags:', error);
this.showNotification('Failed to apply tags', 'error');
}
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
cancelReview() {
this.autoTagState.reviewImages = [];
this.autoTagState.reviewIndex = 0;
this.autoTagState.currentTags = [];
this.hideModal("autoTagReviewModal");
}
showNotification(message, type = 'info') {
// Simple notification - could be enhanced with a toast library
const colors = {
success: 'bg-green-600',
error: 'bg-red-600',
warning: 'bg-yellow-600',
info: 'bg-blue-600'
};
const notification = document.createElement('div');
notification.className = `fixed bottom-4 right-4 ${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg z-50 animate-fade-in`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
}
// Initialize gallery when page loads
document.addEventListener('DOMContentLoaded', () => {
new GalleryManager();
window.gallery = new GalleryManager();
});
// Global helper for modal close buttons
function hideModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
document.body.style.overflow = '';
}
</script>
</body>
</html>