491 lines
20 KiB
JavaScript
491 lines
20 KiB
JavaScript
document.addEventListener('DOMContentLoaded', function() {
|
||
const selectionGrid = document.getElementById('selection-grid');
|
||
// Unified filter state supporting multi-selection for action and orientation
|
||
const filterState = {
|
||
actions: new Set(['all']), // Set of selected action filters
|
||
orientations: new Set(['all']), // Set of selected orientation filters
|
||
resolutions: new Set(['all']),
|
||
nsfw: new Set(['all'])
|
||
};
|
||
// Blur state for NSFW thumbnails (on by default)
|
||
let blurNsfw = true;
|
||
const resolutionSelect = document.getElementById('resolution-select');
|
||
// Search controls
|
||
const searchInput = document.getElementById('search-input');
|
||
const searchButton = document.getElementById('search-button');
|
||
const keywordPillsContainer = document.querySelector('.keyword-pills-container');
|
||
const resolutionPillsContainer = document.querySelector('.resolution-pills');
|
||
const blurToggleBtn = document.getElementById('toggle-blur');
|
||
// initialise button state
|
||
blurToggleBtn.classList.toggle('active', blurNsfw);
|
||
|
||
|
||
// Sorting controls
|
||
const sortFieldSelect = document.getElementById('sort-field');
|
||
const sortDirBtn = document.getElementById('sort-direction');
|
||
|
||
// Sort state
|
||
const sortState = {
|
||
// Keyword search state
|
||
searchKeywords: [],
|
||
field: 'swipe', // 'swipe' | 'created' | 'width' | 'height'
|
||
desc: true,
|
||
};
|
||
const selectAllBtn = document.getElementById('select-all');
|
||
const deselectAllBtn = document.getElementById('deselect-all');
|
||
const downloadSelectedBtn = document.getElementById('download-selected');
|
||
|
||
const actionModal = document.getElementById('action-modal');
|
||
const closeActionModal = document.getElementById('close-action-modal');
|
||
const actionButtons = actionModal.querySelectorAll('.action-btn');
|
||
const modalPreviewImg = document.getElementById('modal-preview-img');
|
||
const modalMessage = document.getElementById('modal-message');
|
||
|
||
const resetBtn = document.getElementById('reset-db');
|
||
// View modal elements
|
||
const viewModal = document.getElementById('view-modal');
|
||
const viewImg = document.getElementById('view-image');
|
||
const viewFilename = document.getElementById('view-filename');
|
||
const viewResolution = document.getElementById('view-resolution');
|
||
const viewCreated = document.getElementById('view-created');
|
||
const viewPrompt = document.getElementById('view-prompt');
|
||
const closeViewModal = document.getElementById('close-view-modal');
|
||
const resetModal = document.getElementById('reset-modal');
|
||
const confirmResetBtn = document.getElementById('confirm-reset');
|
||
const cancelResetBtn = document.getElementById('cancel-reset');
|
||
const resetMessage = document.getElementById('reset-message');
|
||
|
||
let cachedSelections = [];
|
||
// blurNsfw already defined above
|
||
let selectedItems = [];
|
||
let currentSelectionId = null;
|
||
|
||
// Helper to ensure correct /images/ prefix
|
||
const ensureImagePath = (p) => p.startsWith('/images/') ? p : `/images/${p.replace(/^\/+/,'')}`;
|
||
|
||
const loadSelections = () => {
|
||
selectionGrid.innerHTML = `<div class="no-selections">Loading selections...</div>`;
|
||
|
||
fetch('/selections')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.selections && data.selections.length > 0) {
|
||
cachedSelections = data.selections;
|
||
populateResolutionFilter(cachedSelections);
|
||
renderSelections();
|
||
} else {
|
||
selectionGrid.innerHTML = '<div class="no-selections">No selections found</div>';
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error loading selections:', error);
|
||
selectionGrid.innerHTML = `<div class="error">Error loading selections: ${error.message}</div>`;
|
||
});
|
||
};
|
||
|
||
const populateResolutionFilter = (selections) => {
|
||
const resolutions = [...new Set(selections.map(s => s.resolution))].sort();
|
||
// Populate select options (excluding duplicates)
|
||
resolutionSelect.innerHTML = '<option value="all" selected>All Resolutions</option>';
|
||
resolutions.forEach(res => {
|
||
const opt = document.createElement('option');
|
||
opt.value = res;
|
||
opt.textContent = res;
|
||
resolutionSelect.appendChild(opt);
|
||
});
|
||
};
|
||
|
||
const sortSelections = (arr) => {
|
||
const { field, desc } = sortState;
|
||
const dir = desc ? -1 : 1;
|
||
return [...arr].sort((a, b) => {
|
||
let va, vb;
|
||
switch (field) {
|
||
case 'swipe':
|
||
va = a.timestamp || 0;
|
||
vb = b.timestamp || 0;
|
||
break;
|
||
case 'created':
|
||
va = a.creation_date || 0;
|
||
vb = b.creation_date || 0;
|
||
break;
|
||
case 'width':
|
||
va = a.resolution_x || 0;
|
||
vb = b.resolution_x || 0;
|
||
break;
|
||
case 'height':
|
||
va = a.resolution_y || 0;
|
||
vb = b.resolution_y || 0;
|
||
break;
|
||
default:
|
||
va = 0; vb = 0;
|
||
}
|
||
return va === vb ? 0 : (va > vb ? dir : -dir);
|
||
});
|
||
};
|
||
|
||
const renderSelections = () => {
|
||
selectionGrid.innerHTML = '';
|
||
let selections = sortSelections(cachedSelections);
|
||
|
||
const filteredSelections = selections.filter(s =>
|
||
(filterState.actions.has('all') || filterState.actions.has(s.action)) &&
|
||
(filterState.orientations.has('all') || filterState.orientations.has(s.orientation)) &&
|
||
(filterState.resolutions.has('all') || filterState.resolutions.has(s.resolution)) &&
|
||
((filterState.nsfw.has('all')) || (filterState.nsfw.has('nsfw') && s.nsfw) || (filterState.nsfw.has('sfw') && !s.nsfw)) &&
|
||
(sortState.searchKeywords.length === 0 || (() => {
|
||
const haystack = `${s.image_path} ${(s.prompt_data || '')} ${(s.positive_prompt || '')} ${(s.negative_prompt || '')}`.toLowerCase();
|
||
return sortState.searchKeywords.every(k => haystack.includes(k.toLowerCase()));
|
||
})())
|
||
);
|
||
|
||
if (filteredSelections.length === 0) {
|
||
selectionGrid.innerHTML = '<div class="no-selections">No selections match the current filters</div>';
|
||
return;
|
||
}
|
||
|
||
// Ensure image path is absolute and prefixed with /images/
|
||
const ensureImagePath = (p) => {
|
||
if (p.startsWith('/images/')) return p;
|
||
return `/images/${p.replace(/^\/+/, '')}`;
|
||
};
|
||
|
||
filteredSelections.forEach(selection => {
|
||
const blurClass = (blurNsfw && selection.nsfw) ? 'nsfw-blur' : '';
|
||
const item = document.createElement('div');
|
||
item.className = 'selection-item';
|
||
item.dataset.id = selection.id;
|
||
item.innerHTML = `
|
||
<div class="selection-checkbox-container">
|
||
<input type="checkbox" class="selection-checkbox">
|
||
</div>
|
||
<img src="${ensureImagePath(selection.image_path)}" alt="Selected image" loading="lazy" class="${blurClass}">
|
||
<div class="selection-action action-${actionClass(selection.action)}"><img src="/static/icons/${actionIconMap[selection.action]}" alt="${selection.action}" class="selection-action"></div>
|
||
<div class="selection-info">
|
||
<p>${selection.image_path.split('/').pop()}</p>
|
||
<p>Resolution: ${selection.resolution}</p>
|
||
<p>Created: ${formatDate(selection.creation_date)}</p>
|
||
</div>
|
||
<div class="selection-controls">
|
||
<button class="control-btn edit-btn">Change</button>
|
||
<button class="control-btn delete-btn">Remove</button>
|
||
</div>
|
||
`;
|
||
selectionGrid.appendChild(item);
|
||
});
|
||
};
|
||
|
||
// ---- NSFW Blur Toggle ----
|
||
blurToggleBtn.addEventListener('click', () => {
|
||
blurNsfw = !blurNsfw;
|
||
blurToggleBtn.classList.toggle('active', blurNsfw);
|
||
renderSelections();
|
||
});
|
||
|
||
const actionClass = (action) => {
|
||
const map = { 'Discarded':'discard', 'Kept':'keep', 'Favourited':'favorite', 'Reviewing':'review' };
|
||
return map[action] || 'discard';
|
||
};
|
||
|
||
// Map action names to icon filenames for display
|
||
const actionIconMap = {
|
||
'Kept': 'keep.svg',
|
||
'Discarded': 'discard.svg',
|
||
'Favourited': 'fav.svg',
|
||
'Reviewing': 'review.svg'
|
||
};
|
||
const getActionName = (action) => action;
|
||
|
||
const formatDate = (ts) => {
|
||
if (!ts) return 'N/A';
|
||
return new Date(ts * 1000).toLocaleDateString('en-GB'); // Day/Month/Year
|
||
};
|
||
|
||
const updateDownloadButton = () => {
|
||
downloadSelectedBtn.disabled = selectedItems.length === 0;
|
||
downloadSelectedBtn.querySelector('.count').textContent = selectedItems.length > 0 ? selectedItems.length : '0';
|
||
};
|
||
|
||
selectionGrid.addEventListener('click', (e) => {
|
||
const target = e.target;
|
||
const selectionItem = target.closest('.selection-item');
|
||
if (!selectionItem) return;
|
||
|
||
const selectionId = selectionItem.dataset.id;
|
||
const selection = { id: selectionId, image_path: selectionItem.querySelector('img').src };
|
||
|
||
if (target.classList.contains('selection-checkbox')) {
|
||
if (target.checked) {
|
||
selectionItem.classList.add('selected');
|
||
selectedItems.push(selection);
|
||
} else {
|
||
selectionItem.classList.remove('selected');
|
||
selectedItems = selectedItems.filter(item => item.id !== selectionId);
|
||
}
|
||
updateDownloadButton();
|
||
} else if (target.classList.contains('edit-btn')) {
|
||
currentSelectionId = selectionId;
|
||
modalPreviewImg.src = selection.image_path;
|
||
actionModal.style.display = 'flex';
|
||
} else if (target.classList.contains('delete-btn')) {
|
||
if (confirm('Are you sure you want to delete this selection?')) {
|
||
// Implement delete functionality
|
||
}
|
||
} else {
|
||
// Open view modal when clicking elsewhere on the card/image
|
||
const info = cachedSelections.find(s => String(s.id) === String(selectionId));
|
||
if (info) {
|
||
viewImg.src = ensureImagePath(info.image_path);
|
||
viewFilename.textContent = `File: ${info.image_path.split('/').pop()}`;
|
||
viewResolution.textContent = `Resolution: ${info.resolution}`;
|
||
viewCreated.textContent = `Created: ${formatDate(info.creation_date)}`;
|
||
viewPrompt.textContent = info.prompt_data || info.positive_prompt || info.negative_prompt || 'N/A';
|
||
viewModal.style.display = 'flex';
|
||
}
|
||
}
|
||
});
|
||
|
||
// Delegated click handler for any filter button
|
||
document.querySelector('.filter-container').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.filter-btn');
|
||
if (!btn) return;
|
||
|
||
// Determine filter type and value
|
||
const { filter, orientation, nsfw } = btn.dataset;
|
||
// Action filters
|
||
if (filter !== undefined) {
|
||
toggleFilter(btn, 'actions', filter);
|
||
}
|
||
// Orientation filters
|
||
if (orientation !== undefined) {
|
||
toggleFilter(btn, 'orientations', orientation);
|
||
}
|
||
// NSFW filters
|
||
if (nsfw !== undefined) {
|
||
toggleFilter(btn, 'nsfw', nsfw);
|
||
}
|
||
|
||
renderSelections();
|
||
// Helper to toggle filter selections
|
||
function toggleFilter(button, key, value) {
|
||
const set = filterState[key];
|
||
const groupButtons = button.parentElement.querySelectorAll('.filter-btn');
|
||
if (value === 'all') {
|
||
// Selecting 'all' clears other selections
|
||
set.clear();
|
||
set.add('all');
|
||
} else {
|
||
if (set.has('all')) set.delete('all');
|
||
if (set.has(value)) {
|
||
set.delete(value);
|
||
} else {
|
||
set.add(value);
|
||
}
|
||
if (set.size === 0) set.add('all');
|
||
}
|
||
// Update active classes
|
||
groupButtons.forEach(b => {
|
||
const v = b.dataset.filter || b.dataset.orientation || b.dataset.nsfw;
|
||
b.classList.toggle('active', set.has(v));
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---------------- Search keyword handling ----------------
|
||
const renderKeywordPills = () => {
|
||
keywordPillsContainer.innerHTML = '';
|
||
sortState.searchKeywords.forEach(keyword => {
|
||
const pill = document.createElement('div');
|
||
pill.className = 'keyword-pill';
|
||
pill.textContent = keyword;
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.className = 'remove-keyword';
|
||
removeBtn.innerHTML = '×';
|
||
removeBtn.dataset.keyword = keyword;
|
||
pill.appendChild(removeBtn);
|
||
|
||
keywordPillsContainer.appendChild(pill);
|
||
});
|
||
};
|
||
|
||
const addSearchKeyword = () => {
|
||
const newKeyword = searchInput.value.trim();
|
||
if (newKeyword && !sortState.searchKeywords.includes(newKeyword)) {
|
||
sortState.searchKeywords.push(newKeyword);
|
||
renderKeywordPills();
|
||
renderSelections();
|
||
}
|
||
searchInput.value = '';
|
||
searchInput.focus();
|
||
};
|
||
|
||
searchButton.addEventListener('click', addSearchKeyword);
|
||
searchInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') addSearchKeyword();
|
||
});
|
||
|
||
keywordPillsContainer.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('remove-keyword')) {
|
||
const keywordToRemove = e.target.dataset.keyword;
|
||
sortState.searchKeywords = sortState.searchKeywords.filter(k => k !== keywordToRemove);
|
||
renderKeywordPills();
|
||
renderSelections();
|
||
}
|
||
});
|
||
|
||
// ---------------- Resolution dropdown & pills ----------------
|
||
const addResolutionPill = (value) => {
|
||
const pill = document.createElement('span');
|
||
pill.className = 'keyword-pill';
|
||
pill.textContent = value;
|
||
const x = document.createElement('span');
|
||
x.className = 'remove-keyword';
|
||
x.textContent = '×';
|
||
pill.appendChild(x);
|
||
resolutionPillsContainer.appendChild(pill);
|
||
pill.addEventListener('click', () => {
|
||
filterState.resolutions.delete(value);
|
||
pill.remove();
|
||
if (filterState.resolutions.size === 0) filterState.resolutions.add('all');
|
||
renderSelections();
|
||
});
|
||
};
|
||
|
||
resolutionSelect.addEventListener('change', () => {
|
||
const val = resolutionSelect.value;
|
||
if (val === 'all') {
|
||
filterState.resolutions.clear();
|
||
filterState.resolutions.add('all');
|
||
resolutionPillsContainer.innerHTML = '';
|
||
} else if (!filterState.resolutions.has(val)) {
|
||
if (filterState.resolutions.has('all')) filterState.resolutions.delete('all');
|
||
filterState.resolutions.add(val);
|
||
addResolutionPill(val);
|
||
}
|
||
resolutionSelect.value = 'all';
|
||
renderSelections();
|
||
});
|
||
|
||
// ---------------- Sorting controls ----------------
|
||
sortFieldSelect.addEventListener('change', () => {
|
||
sortState.field = sortFieldSelect.value;
|
||
renderSelections();
|
||
});
|
||
|
||
sortDirBtn.addEventListener('click', () => {
|
||
sortState.desc = !sortState.desc;
|
||
sortDirBtn.innerHTML = sortState.desc ? '▼' : '▲';
|
||
renderSelections();
|
||
});
|
||
|
||
selectAllBtn.addEventListener('click', () => {
|
||
document.querySelectorAll('.selection-checkbox').forEach(cb => cb.checked = true);
|
||
selectedItems = Array.from(document.querySelectorAll('.selection-item')).map(item => ({id: item.dataset.id, image_path: item.querySelector('img').src}));
|
||
document.querySelectorAll('.selection-item').forEach(item => item.classList.add('selected'));
|
||
updateDownloadButton();
|
||
});
|
||
|
||
deselectAllBtn.addEventListener('click', () => {
|
||
document.querySelectorAll('.selection-checkbox').forEach(cb => cb.checked = false);
|
||
selectedItems = [];
|
||
document.querySelectorAll('.selection-item').forEach(item => item.classList.remove('selected'));
|
||
updateDownloadButton();
|
||
});
|
||
|
||
downloadSelectedBtn.addEventListener('click', () => {
|
||
const paths = selectedItems.map(item => item.image_path);
|
||
const query = paths.map(p => `paths=${encodeURIComponent(p)}`).join('&');
|
||
window.location.href = `/download-selected?${query}`;
|
||
});
|
||
|
||
closeActionModal.addEventListener('click', () => actionModal.style.display = 'none');
|
||
|
||
actionButtons.forEach(button => button.addEventListener('click', async function() {
|
||
const direction = this.dataset.action;
|
||
const actionMap = { left: 'Discarded', right: 'Kept', up: 'Favourited', down: 'Reviewing' };
|
||
const actionName = actionMap[direction] || direction;
|
||
if (!currentSelectionId) return;
|
||
|
||
// Show loading state
|
||
modalMessage.textContent = 'Updating...';
|
||
modalMessage.style.color = '#ffffff';
|
||
|
||
try {
|
||
const response = await fetch('/update-selection', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: currentSelectionId, action: actionName })
|
||
});
|
||
const data = await response.json();
|
||
if (response.ok && data.success) {
|
||
// Update cached selection locally
|
||
const sel = cachedSelections.find(s => String(s.id) === String(currentSelectionId));
|
||
if (sel) sel.action = actionName;
|
||
|
||
// Refresh grid to reflect changes
|
||
renderSelections();
|
||
|
||
// Success feedback
|
||
modalMessage.textContent = 'Action updated!';
|
||
modalMessage.style.color = '#2ecc71';
|
||
|
||
// Close modal after short delay
|
||
setTimeout(() => {
|
||
actionModal.style.display = 'none';
|
||
modalMessage.textContent = '';
|
||
}, 800);
|
||
} else {
|
||
throw new Error(data.message || 'Failed to update');
|
||
}
|
||
} catch (err) {
|
||
console.error('Error updating action:', err);
|
||
modalMessage.textContent = `Error: ${err.message}`;
|
||
modalMessage.style.color = '#e74c3c';
|
||
}
|
||
}));
|
||
|
||
resetBtn.addEventListener('click', () => resetModal.style.display = 'flex');
|
||
confirmResetBtn.addEventListener('click', () => {
|
||
resetMessage.textContent = '';
|
||
confirmResetBtn.disabled = true;
|
||
fetch('/reset-database', {
|
||
method: 'POST',
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status === 'success') {
|
||
resetMessage.textContent = 'Database reset successfully!';
|
||
resetMessage.style.color = 'green';
|
||
setTimeout(() => {
|
||
resetModal.style.display = 'none';
|
||
loadSelections();
|
||
confirmResetBtn.disabled = false;
|
||
}, 1500);
|
||
} else {
|
||
throw new Error(data.message || 'An unknown error occurred.');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error resetting database:', error);
|
||
resetMessage.textContent = `Error: ${error.message}`;
|
||
resetMessage.style.color = 'red';
|
||
confirmResetBtn.disabled = false;
|
||
});
|
||
});
|
||
cancelResetBtn.addEventListener('click', () => resetModal.style.display = 'none');
|
||
|
||
// ---- View modal close ----
|
||
closeViewModal.addEventListener('click', () => viewModal.style.display = 'none');
|
||
viewModal.addEventListener('click', (e) => {
|
||
if (e.target === viewModal) viewModal.style.display = 'none';
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && viewModal.style.display === 'flex') {
|
||
viewModal.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
loadSelections();
|
||
});
|