feat(bulk): consensus ML suggestions + frontend polish
Bulk editor now loads consensus ML suggestions across the current selection via a new POST /api/suggestions/bulk endpoint, powered by get_bulk_suggestions() in tag_suggestions.py. A tag is surfaced only if it was suggested for or already applied to >= 80% of the selection; coverage counts include images that already have the tag so a near- universal tag isn't penalized for dropping out of suggestion lists. Accept-only chips (no reject) match the rest of the suggestions UX. Frontend polish in the same commit: - Showcase session dedup (exclude seen ids, reset on second lap) and aspect-ratio-aware placeholder heights for better column balancing - Modal touch-swipe navigation on the image wrapper, auto-focus of the tag input on desktop (>=768px), and autocomplete flip-up when the dropdown would cover the Suggestions section - Settings template inline styles replaced with utility classes - Inline series-editor script extracted to gallery-series-editor.js Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+30
@@ -95,6 +95,8 @@ def random_images_api():
|
||||
'thumb_url': url_for('main.serve_image', filename=thumb_path),
|
||||
'full_url': url_for('main.serve_image', filename=full_path),
|
||||
'is_video': is_video,
|
||||
'width': img.width,
|
||||
'height': img.height,
|
||||
'date': (img.taken_at or img.imported_at).strftime('%Y-%m-%d') if (img.taken_at or img.imported_at) else '',
|
||||
'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags]
|
||||
})
|
||||
@@ -714,6 +716,34 @@ def get_image_suggestions(image_id):
|
||||
return jsonify({'ok': True, 'suggestions': suggestions})
|
||||
|
||||
|
||||
@main.post("/api/suggestions/bulk")
|
||||
def get_bulk_suggestions_api():
|
||||
"""Consensus ML suggestions across a selection of images.
|
||||
|
||||
Body: {"image_ids": [...], "threshold": 0.8}
|
||||
Returns grouped suggestions where each tag was suggested for or already
|
||||
applied to at least `threshold` fraction of the selected images.
|
||||
"""
|
||||
from app.services.tag_suggestions import get_bulk_suggestions
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
image_ids = payload.get('image_ids') or []
|
||||
try:
|
||||
image_ids = [int(x) for x in image_ids]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'ok': False, 'error': 'invalid_image_ids'}), 400
|
||||
if not image_ids:
|
||||
return jsonify({'ok': False, 'error': 'no_image_ids'}), 400
|
||||
|
||||
try:
|
||||
threshold = float(payload.get('threshold', 0.8))
|
||||
except (TypeError, ValueError):
|
||||
threshold = 0.8
|
||||
threshold = max(0.0, min(1.0, threshold))
|
||||
|
||||
suggestions = get_bulk_suggestions(image_ids, threshold=threshold)
|
||||
return jsonify({'ok': True, 'suggestions': suggestions, 'evaluated': len(image_ids), 'threshold': threshold})
|
||||
|
||||
|
||||
@main.post("/image/<int:image_id>/suggestions/accept")
|
||||
def accept_image_suggestion(image_id):
|
||||
from app.models import SuggestionFeedback
|
||||
|
||||
@@ -197,7 +197,13 @@ def _merge(wd14: list[dict], embedding: list[dict]) -> list[dict]:
|
||||
return list(by_name.values())
|
||||
|
||||
|
||||
def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
|
||||
def get_suggestions(
|
||||
image_id: int,
|
||||
top_k_per_category: int = 10,
|
||||
*,
|
||||
cfg: dict | None = None,
|
||||
existing_all: set[str] | None = None,
|
||||
) -> dict:
|
||||
"""Return suggestions grouped by category for one image.
|
||||
|
||||
Shape:
|
||||
@@ -207,11 +213,15 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
|
||||
'general': [...],
|
||||
}
|
||||
Ordered by confidence desc within each category. 'meta' and 'rating' are omitted.
|
||||
|
||||
The optional `cfg` and `existing_all` kwargs let callers that loop over many
|
||||
images amortize the config fetch and the Tag-table scan.
|
||||
"""
|
||||
if ImageRecord.query.get(image_id) is None:
|
||||
return {'character': [], 'copyright': [], 'general': []}
|
||||
|
||||
cfg = _config()
|
||||
if cfg is None:
|
||||
cfg = _config()
|
||||
already = _existing_tag_names_for_image(image_id)
|
||||
|
||||
merged = _merge(
|
||||
@@ -219,7 +229,8 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
|
||||
_embedding_tag_suggestions(image_id, cfg, already),
|
||||
)
|
||||
|
||||
existing_all = _existing_tag_names()
|
||||
if existing_all is None:
|
||||
existing_all = _existing_tag_names()
|
||||
|
||||
grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
|
||||
for s in merged:
|
||||
@@ -233,3 +244,95 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
|
||||
grouped[cat] = grouped[cat][:top_k_per_category]
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
def get_bulk_suggestions(
|
||||
image_ids: list[int],
|
||||
threshold: float = 0.8,
|
||||
top_k_per_category: int = 10,
|
||||
) -> dict:
|
||||
"""Return consensus suggestions across multiple selected images.
|
||||
|
||||
A tag is included only if it was suggested for (or already applied to)
|
||||
at least `threshold` fraction of the selected images. Confidence returned
|
||||
is the mean over the images where it was actually suggested; images that
|
||||
already have the tag contribute to coverage but not to confidence.
|
||||
|
||||
Shape matches get_suggestions(): {'character': [...], 'copyright': [...], 'general': [...]}.
|
||||
Each item also carries 'coverage' (float in [0, 1]) and 'covered_count' (int)
|
||||
so the UI can display how broad the consensus is.
|
||||
"""
|
||||
if not image_ids:
|
||||
return {'character': [], 'copyright': [], 'general': []}
|
||||
|
||||
cfg = _config()
|
||||
existing_all = _existing_tag_names()
|
||||
total = len(image_ids)
|
||||
|
||||
# Batched lookup: which selected images already have which tags. Used so a
|
||||
# tag that's already on most of the selection isn't penalized just because
|
||||
# it drops out of those images' suggestion lists.
|
||||
existing_by_image: dict[int, set[str]] = {iid: set() for iid in image_ids}
|
||||
rows = (
|
||||
db.session.query(image_tags.c.image_id, Tag.name)
|
||||
.join(Tag, Tag.id == image_tags.c.tag_id)
|
||||
.filter(image_tags.c.image_id.in_(image_ids))
|
||||
.all()
|
||||
)
|
||||
for iid, name in rows:
|
||||
existing_by_image.setdefault(iid, set()).add(name)
|
||||
|
||||
# Aggregate suggested-count and confidence per tag name.
|
||||
# suggested_count and existing_count are disjoint because get_suggestions()
|
||||
# excludes tags the image already has.
|
||||
tag_stats: dict[str, dict] = {}
|
||||
for image_id in image_ids:
|
||||
grouped = get_suggestions(
|
||||
image_id,
|
||||
top_k_per_category=top_k_per_category,
|
||||
cfg=cfg,
|
||||
existing_all=existing_all,
|
||||
)
|
||||
for category, items in grouped.items():
|
||||
for item in items:
|
||||
name = item['name']
|
||||
stat = tag_stats.get(name)
|
||||
if stat is None:
|
||||
stat = {
|
||||
'name': name,
|
||||
'category': category,
|
||||
'suggested_count': 0,
|
||||
'sum_conf': 0.0,
|
||||
'source': item['source'],
|
||||
}
|
||||
tag_stats[name] = stat
|
||||
stat['suggested_count'] += 1
|
||||
stat['sum_conf'] += item['confidence']
|
||||
|
||||
# Only tags that appeared somewhere as suggestions are candidates. Tags
|
||||
# already universally applied don't appear here — correctly, since there's
|
||||
# nothing for Accept to do.
|
||||
result: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
|
||||
for stat in tag_stats.values():
|
||||
existing_count = sum(
|
||||
1 for iid in image_ids if stat['name'] in existing_by_image.get(iid, set())
|
||||
)
|
||||
covered_count = stat['suggested_count'] + existing_count
|
||||
coverage = covered_count / total
|
||||
if coverage < threshold:
|
||||
continue
|
||||
mean_conf = stat['sum_conf'] / stat['suggested_count']
|
||||
result[stat['category']].append({
|
||||
'name': stat['name'],
|
||||
'category': stat['category'],
|
||||
'confidence': mean_conf,
|
||||
'coverage': coverage,
|
||||
'covered_count': covered_count,
|
||||
'source': stat['source'],
|
||||
'exists_in_db': stat['name'] in existing_all,
|
||||
})
|
||||
|
||||
for cat in result:
|
||||
result[cat].sort(key=lambda x: x['confidence'], reverse=True)
|
||||
result[cat] = result[cat][:top_k_per_category]
|
||||
return result
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
dom.tagInput = document.getElementById('bulkTagInput');
|
||||
dom.autocomplete = document.getElementById('bulkTagAutocomplete');
|
||||
dom.commonTags = document.getElementById('commonTagsList');
|
||||
dom.bulkSuggestionsList = document.getElementById('bulkSuggestionsList');
|
||||
dom.bulkSuggestionsHint = document.getElementById('bulkSuggestionsHint');
|
||||
|
||||
if (!dom.selectBtn || !dom.panel) return;
|
||||
|
||||
@@ -124,6 +126,7 @@
|
||||
updateAllOrderBadges();
|
||||
updateCount();
|
||||
loadCommonTags();
|
||||
loadBulkSuggestions();
|
||||
dispatchSelectionChanged();
|
||||
}
|
||||
|
||||
@@ -137,6 +140,7 @@
|
||||
});
|
||||
updateCount();
|
||||
updateCommonTagsDisplay();
|
||||
clearBulkSuggestions();
|
||||
dispatchSelectionChanged();
|
||||
}
|
||||
|
||||
@@ -288,6 +292,133 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Bulk ML suggestions (consensus across selection)
|
||||
// ---------------------------
|
||||
const BULK_SUGGESTIONS_MIN = 2;
|
||||
const BULK_SUGGESTIONS_THRESHOLD = 0.8;
|
||||
const BULK_SUGGESTIONS_CATEGORY_ORDER = ['character', 'copyright', 'general'];
|
||||
const BULK_SUGGESTIONS_CATEGORY_LABEL = {
|
||||
character: 'Characters',
|
||||
copyright: 'Fandoms',
|
||||
general: 'General',
|
||||
};
|
||||
|
||||
function clearBulkSuggestions() {
|
||||
if (dom.bulkSuggestionsList) dom.bulkSuggestionsList.innerHTML = '';
|
||||
setBulkSuggestionsHint('Select 2+ images to see consensus suggestions.');
|
||||
}
|
||||
|
||||
function setBulkSuggestionsHint(text, visible = true) {
|
||||
if (!dom.bulkSuggestionsHint) return;
|
||||
dom.bulkSuggestionsHint.textContent = text;
|
||||
dom.bulkSuggestionsHint.style.display = visible ? '' : 'none';
|
||||
}
|
||||
|
||||
async function loadBulkSuggestions() {
|
||||
if (!dom.bulkSuggestionsList) return;
|
||||
const ids = Array.from(state.selectedIds);
|
||||
if (ids.length < BULK_SUGGESTIONS_MIN) {
|
||||
clearBulkSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
setBulkSuggestionsHint('Loading…');
|
||||
dom.bulkSuggestionsList.innerHTML = '';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/suggestions/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_ids: ids, threshold: BULK_SUGGESTIONS_THRESHOLD }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) {
|
||||
setBulkSuggestionsHint('Could not load suggestions.');
|
||||
return;
|
||||
}
|
||||
renderBulkSuggestions(j.suggestions || {}, ids.length);
|
||||
} catch (e) {
|
||||
setBulkSuggestionsHint('Could not load suggestions.');
|
||||
}
|
||||
}
|
||||
|
||||
function renderBulkSuggestions(grouped, selectionSize) {
|
||||
if (!dom.bulkSuggestionsList) return;
|
||||
dom.bulkSuggestionsList.innerHTML = '';
|
||||
|
||||
let total = 0;
|
||||
for (const cat of BULK_SUGGESTIONS_CATEGORY_ORDER) {
|
||||
const items = (grouped && grouped[cat]) || [];
|
||||
if (items.length === 0) continue;
|
||||
total += items.length;
|
||||
|
||||
const group = document.createElement('div');
|
||||
group.className = 'suggestions-category';
|
||||
group.dataset.category = cat;
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'suggestions-category-label';
|
||||
label.textContent = BULK_SUGGESTIONS_CATEGORY_LABEL[cat] || cat;
|
||||
group.appendChild(label);
|
||||
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'suggestions-chips';
|
||||
for (const item of items) {
|
||||
chips.appendChild(buildBulkSuggestionChip(item, selectionSize));
|
||||
}
|
||||
group.appendChild(chips);
|
||||
dom.bulkSuggestionsList.appendChild(group);
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
setBulkSuggestionsHint('No consensus suggestions for this selection.');
|
||||
} else {
|
||||
setBulkSuggestionsHint('', false);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBulkSuggestionChip(item, selectionSize) {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'suggestion-chip suggestion-chip-bulk';
|
||||
chip.dataset.tagName = item.name;
|
||||
chip.dataset.category = item.category;
|
||||
const pct = Math.round((item.confidence || 0) * 100);
|
||||
const covered = item.covered_count != null ? item.covered_count : selectionSize;
|
||||
chip.title = `${item.name} — suggested for ${covered}/${selectionSize}`;
|
||||
|
||||
chip.innerHTML = `
|
||||
<button type="button" class="sugg-btn sugg-accept" aria-label="Add to all selected">✓</button>
|
||||
<span class="sugg-body">
|
||||
<span class="sugg-label">${escapeHtml(item.name)}</span>
|
||||
<span class="sugg-confidence">${pct}% · ${covered}/${selectionSize}</span>
|
||||
</span>
|
||||
`;
|
||||
|
||||
const acceptBtn = chip.querySelector('.sugg-accept');
|
||||
if (acceptBtn) {
|
||||
acceptBtn.addEventListener('click', () => acceptBulkSuggestion(chip));
|
||||
}
|
||||
return chip;
|
||||
}
|
||||
|
||||
async function acceptBulkSuggestion(chip) {
|
||||
if (chip.dataset.disabled === 'true') return;
|
||||
chip.dataset.disabled = 'true';
|
||||
chip.style.pointerEvents = 'none';
|
||||
chip.style.opacity = '0.5';
|
||||
const name = chip.dataset.tagName;
|
||||
try {
|
||||
await bulkAddTag(name);
|
||||
chip.classList.add('leaving');
|
||||
setTimeout(() => chip.remove(), 200);
|
||||
} catch {
|
||||
chip.dataset.disabled = 'false';
|
||||
chip.style.pointerEvents = '';
|
||||
chip.style.opacity = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkAddTag(e) {
|
||||
e.preventDefault();
|
||||
const name = dom.tagInput.value.trim();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// /app/static/js/gallery-series-editor.js
|
||||
// Series editor wired into the bulk-editor panel on series-filtered gallery views.
|
||||
// No-ops when the page has no series context.
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const seriesInfo = window.galleryState && window.galleryState.seriesInfo;
|
||||
if (!seriesInfo) return;
|
||||
|
||||
const startPageInput = document.getElementById('startPageInput');
|
||||
const bulkAddToSeriesBtn = document.getElementById('bulkAddToSeriesBtn');
|
||||
const feedbackEl = document.getElementById('seriesEditorFeedback');
|
||||
if (!startPageInput || !bulkAddToSeriesBtn || !feedbackEl) return;
|
||||
|
||||
const seriesTagId = seriesInfo.tag_id;
|
||||
|
||||
function updateAddButtonState() {
|
||||
const selectedCount = window.selectedImages ? window.selectedImages.size : 0;
|
||||
bulkAddToSeriesBtn.disabled = selectedCount === 0;
|
||||
bulkAddToSeriesBtn.textContent = selectedCount > 0
|
||||
? `Add ${selectedCount} to Series`
|
||||
: 'Add to Series';
|
||||
}
|
||||
|
||||
document.addEventListener('selectionChanged', updateAddButtonState);
|
||||
|
||||
bulkAddToSeriesBtn.addEventListener('click', async () => {
|
||||
if (!window.selectedImages || window.selectedImages.size === 0) return;
|
||||
|
||||
const imageIds = window.selectionOrder ? [...window.selectionOrder] : Array.from(window.selectedImages);
|
||||
const startPage = parseInt(startPageInput.value) || 1;
|
||||
|
||||
bulkAddToSeriesBtn.disabled = true;
|
||||
bulkAddToSeriesBtn.textContent = 'Adding...';
|
||||
feedbackEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/series/${seriesTagId}/pages/bulk-add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_ids: imageIds, start_page: startPage })
|
||||
});
|
||||
const data = await r.json();
|
||||
|
||||
if (data.ok) {
|
||||
let msg = `Added ${data.added.length} image(s) to series.`;
|
||||
if (data.skipped.length > 0) {
|
||||
msg += ` Skipped ${data.skipped.length}.`;
|
||||
}
|
||||
feedbackEl.innerHTML = `<div class="feedback-success">${msg}</div>`;
|
||||
|
||||
if (data.added.length > 0) {
|
||||
const lastPage = data.added[data.added.length - 1].page_number;
|
||||
startPageInput.value = lastPage + 1;
|
||||
}
|
||||
|
||||
if (window.clearSelection) {
|
||||
window.clearSelection();
|
||||
}
|
||||
|
||||
const pageCountEl = document.getElementById('seriesPageCount');
|
||||
if (pageCountEl) {
|
||||
const currentCount = parseInt(pageCountEl.textContent) || 0;
|
||||
pageCountEl.textContent = `${currentCount + data.added.length} pages`;
|
||||
}
|
||||
|
||||
// "Read Series" button only renders when page_count > 0. If this was the first
|
||||
// batch of pages added, reload so the button appears.
|
||||
const readBtn = document.querySelector('.series-header-actions .primary-btn');
|
||||
if (!readBtn && data.added.length > 0) {
|
||||
location.reload();
|
||||
}
|
||||
} else {
|
||||
feedbackEl.innerHTML = `<div class="feedback-error">${data.error}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
feedbackEl.innerHTML = `<div class="feedback-error">Error: ${e.message}</div>`;
|
||||
} finally {
|
||||
updateAddButtonState();
|
||||
}
|
||||
});
|
||||
|
||||
updateAddButtonState();
|
||||
});
|
||||
@@ -20,6 +20,15 @@
|
||||
let columnHeights = [];
|
||||
let isLoading = false;
|
||||
let currentColumnCount = 0;
|
||||
const seenImageIds = new Set();
|
||||
|
||||
function buildRandomUrl(count) {
|
||||
const params = new URLSearchParams({ count: String(count) });
|
||||
if (seenImageIds.size > 0) {
|
||||
params.set('exclude', [...seenImageIds].join(','));
|
||||
}
|
||||
return `/api/random-images?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
@@ -149,6 +158,13 @@
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = img.thumb_url;
|
||||
imgEl.alt = img.filename;
|
||||
// Reserve the correct layout slot before the image loads so the masonry
|
||||
// doesn't reflow when thumbnails arrive. Width/height populate on import
|
||||
// and may be missing on older records — fall back to natural sizing.
|
||||
if (img.width && img.height) {
|
||||
imgEl.width = img.width;
|
||||
imgEl.height = img.height;
|
||||
}
|
||||
if (useLazyLoading) {
|
||||
imgEl.loading = 'lazy';
|
||||
}
|
||||
@@ -173,7 +189,10 @@
|
||||
}
|
||||
|
||||
function distributeImages(images, useLazyLoading = false, animate = false) {
|
||||
const colWidth = columns[0] ? columns[0].clientWidth : 0;
|
||||
|
||||
images.forEach((img, i) => {
|
||||
seenImageIds.add(img.id);
|
||||
const colIndex = getShortestColumnIndex();
|
||||
const item = createImageElement(img, useLazyLoading);
|
||||
|
||||
@@ -186,7 +205,13 @@
|
||||
}
|
||||
|
||||
columns[colIndex].appendChild(item);
|
||||
columnHeights[colIndex] += 300;
|
||||
// Predict rendered height from the intrinsic aspect ratio so masonry
|
||||
// packing is accurate before the thumb loads. Fall back to a flat guess
|
||||
// when W/H are missing.
|
||||
const predictedHeight = (colWidth && img.width && img.height)
|
||||
? colWidth * (img.height / img.width)
|
||||
: 300;
|
||||
columnHeights[colIndex] += predictedHeight;
|
||||
const imgEl = item.querySelector('img');
|
||||
imgEl.onload = () => { updateColumnHeights(); };
|
||||
});
|
||||
@@ -228,10 +253,18 @@
|
||||
container.classList.add('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/random-images?count=20`);
|
||||
let response = await fetch(buildRandomUrl(20));
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
let data = await response.json();
|
||||
|
||||
const data = await response.json();
|
||||
// If the collection is exhausted for this session, reset and retry once
|
||||
// so shuffle keeps feeling fresh on a "second lap".
|
||||
if (data.images.length === 0 && seenImageIds.size > 0) {
|
||||
seenImageIds.clear();
|
||||
response = await fetch(buildRandomUrl(20));
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
data = await response.json();
|
||||
}
|
||||
|
||||
// Clear everything
|
||||
columns.forEach(col => col.innerHTML = '');
|
||||
@@ -254,7 +287,7 @@
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/random-images?count=${LOAD_BATCH_SIZE}`);
|
||||
const response = await fetch(buildRandomUrl(LOAD_BATCH_SIZE));
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -630,9 +630,30 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
function positionAutocomplete() {
|
||||
if (!tagAutocomplete || !tagInput) return;
|
||||
const rect = tagInput.getBoundingClientRect();
|
||||
tagAutocomplete.style.top = `${rect.bottom + 4}px`;
|
||||
const gap = 4;
|
||||
tagAutocomplete.style.left = `${rect.left}px`;
|
||||
tagAutocomplete.style.width = `${rect.width}px`;
|
||||
|
||||
// Measure the dropdown height. Requires the element to be visible
|
||||
// (callers add the `active` class before invoking this function).
|
||||
const dropdownHeight = tagAutocomplete.offsetHeight;
|
||||
|
||||
// If the Suggestions section sits below the input and the default
|
||||
// downward dropdown would overlap it, flip the dropdown above the
|
||||
// input so the user can still see their suggestion chips.
|
||||
let flipUp = false;
|
||||
if (suggestionsSection && suggestionsSection.offsetParent !== null) {
|
||||
const suggestionsRect = suggestionsSection.getBoundingClientRect();
|
||||
const wouldOverlap = (rect.bottom + gap + dropdownHeight) > suggestionsRect.top;
|
||||
const fitsAbove = (rect.top - gap - dropdownHeight) >= 0;
|
||||
if (wouldOverlap && fitsAbove) flipUp = true;
|
||||
}
|
||||
|
||||
if (flipUp) {
|
||||
tagAutocomplete.style.top = `${rect.top - gap - dropdownHeight}px`;
|
||||
} else {
|
||||
tagAutocomplete.style.top = `${rect.bottom + gap}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAutocomplete(tags) {
|
||||
@@ -642,8 +663,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (tags.length === 0) {
|
||||
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
|
||||
positionAutocomplete();
|
||||
tagAutocomplete.classList.add('active');
|
||||
positionAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -655,8 +676,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<span class="tag-kind">${t.kind || 'user'}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
positionAutocomplete();
|
||||
tagAutocomplete.classList.add('active');
|
||||
positionAutocomplete();
|
||||
}
|
||||
|
||||
function selectAutocompleteItem(index) {
|
||||
@@ -810,6 +831,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
function openModal(index) {
|
||||
modal.classList.add('active');
|
||||
updateImage(index);
|
||||
// Auto-focus the tag input on desktop so users can start typing immediately.
|
||||
// Skipped on mobile — the on-screen keyboard would shove the image out of
|
||||
// view and focus feels unexpected on touch.
|
||||
if (tagInput && window.innerWidth >= 768) {
|
||||
tagInput.focus();
|
||||
}
|
||||
history.replaceState({ modalIndex: index }, '', window.location.href);
|
||||
}
|
||||
|
||||
@@ -978,6 +1005,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
toggleZoom();
|
||||
});
|
||||
|
||||
// Touch-swipe prev/next. Skipped when zoomed (drag-to-pan wins) or on
|
||||
// multi-touch (pinch). Setting dragMoved suppresses the synthesized
|
||||
// click that would otherwise toggle zoom after navigation.
|
||||
const SWIPE_MIN_DISTANCE = 50;
|
||||
const SWIPE_DOMINANCE = 1.5;
|
||||
let touchStartX = null;
|
||||
let touchStartY = null;
|
||||
|
||||
modalImageWrapper.addEventListener('touchstart', (e) => {
|
||||
if (isZoomed || e.touches.length !== 1) {
|
||||
touchStartX = null;
|
||||
return;
|
||||
}
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
modalImageWrapper.addEventListener('touchend', (e) => {
|
||||
if (touchStartX === null) return;
|
||||
const touch = e.changedTouches[0];
|
||||
const dx = touch.clientX - touchStartX;
|
||||
const dy = touch.clientY - touchStartY;
|
||||
touchStartX = null;
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
if (absDx < SWIPE_MIN_DISTANCE) return;
|
||||
if (absDx < absDy * SWIPE_DOMINANCE) return;
|
||||
dragMoved = true;
|
||||
if (dx > 0) showPrev(); else showNext();
|
||||
});
|
||||
|
||||
modalClose?.addEventListener('click', closeModal);
|
||||
modalPrev?.addEventListener('click', showPrev);
|
||||
modalNext?.addEventListener('click', showNext);
|
||||
|
||||
@@ -144,6 +144,11 @@
|
||||
<p class="form-help">Common tags across selected images:</p>
|
||||
<div id="commonTagsList" class="tags"></div>
|
||||
</div>
|
||||
<div class="bulk-editor-section">
|
||||
<h4>Suggestions</h4>
|
||||
<p class="form-help" id="bulkSuggestionsHint">Select 2+ images to see consensus suggestions.</p>
|
||||
<div id="bulkSuggestionsList" class="suggestions-list"></div>
|
||||
</div>
|
||||
{% if series_info %}
|
||||
<div class="bulk-editor-section bulk-editor-series">
|
||||
<h4>📚 Add to Series</h4>
|
||||
@@ -179,91 +184,7 @@
|
||||
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script>
|
||||
|
||||
{% if series_info %}
|
||||
<script>
|
||||
// Series Editor functionality (integrated into bulk editor panel)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const startPageInput = document.getElementById('startPageInput');
|
||||
const bulkAddToSeriesBtn = document.getElementById('bulkAddToSeriesBtn');
|
||||
const feedbackEl = document.getElementById('seriesEditorFeedback');
|
||||
const seriesTagId = {{ series_info.tag_id }};
|
||||
|
||||
// Update button state based on selection
|
||||
function updateAddButtonState() {
|
||||
const selectedCount = window.selectedImages ? window.selectedImages.size : 0;
|
||||
bulkAddToSeriesBtn.disabled = selectedCount === 0;
|
||||
bulkAddToSeriesBtn.textContent = selectedCount > 0
|
||||
? `Add ${selectedCount} to Series`
|
||||
: 'Add to Series';
|
||||
}
|
||||
|
||||
// Listen for selection changes
|
||||
document.addEventListener('selectionChanged', updateAddButtonState);
|
||||
|
||||
// Add selected images to series
|
||||
bulkAddToSeriesBtn.addEventListener('click', async () => {
|
||||
if (!window.selectedImages || window.selectedImages.size === 0) return;
|
||||
|
||||
// Use selectionOrder (click order) for page ordering
|
||||
const imageIds = window.selectionOrder ? [...window.selectionOrder] : Array.from(window.selectedImages);
|
||||
const startPage = parseInt(startPageInput.value) || 1;
|
||||
|
||||
bulkAddToSeriesBtn.disabled = true;
|
||||
bulkAddToSeriesBtn.textContent = 'Adding...';
|
||||
feedbackEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/series/${seriesTagId}/pages/bulk-add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_ids: imageIds, start_page: startPage })
|
||||
});
|
||||
const data = await r.json();
|
||||
|
||||
if (data.ok) {
|
||||
let msg = `Added ${data.added.length} image(s) to series.`;
|
||||
if (data.skipped.length > 0) {
|
||||
msg += ` Skipped ${data.skipped.length}.`;
|
||||
}
|
||||
feedbackEl.innerHTML = `<div class="feedback-success">${msg}</div>`;
|
||||
|
||||
// Update the starting page input to the next available page
|
||||
if (data.added.length > 0) {
|
||||
const lastPage = data.added[data.added.length - 1].page_number;
|
||||
startPageInput.value = lastPage + 1;
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
if (window.clearSelection) {
|
||||
window.clearSelection();
|
||||
}
|
||||
|
||||
// Update page count in header
|
||||
const pageCountEl = document.getElementById('seriesPageCount');
|
||||
if (pageCountEl) {
|
||||
const currentCount = parseInt(pageCountEl.textContent) || 0;
|
||||
pageCountEl.textContent = `${currentCount + data.added.length} pages`;
|
||||
}
|
||||
|
||||
// Show "Read Series" button if it was hidden (first pages added)
|
||||
const readBtn = document.querySelector('.series-header-actions .primary-btn');
|
||||
if (!readBtn && data.added.length > 0) {
|
||||
location.reload(); // Simplest way to update the UI
|
||||
}
|
||||
} else {
|
||||
feedbackEl.innerHTML = `<div class="feedback-error">${data.error}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
feedbackEl.innerHTML = `<div class="feedback-error">Error: ${e.message}</div>`;
|
||||
} finally {
|
||||
updateAddButtonState();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state
|
||||
updateAddButtonState();
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/gallery-series-editor.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
+237
-63
@@ -19,14 +19,14 @@
|
||||
<div class="settings-grid-full">
|
||||
<div class="settings-container">
|
||||
<div class="settings-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||
<h2 style="margin: 0;">System Overview</h2>
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<span id="statsUpdatedAt" style="font-size: 0.75rem; color: var(--text-muted);"></span>
|
||||
<button id="refreshStatsBtn" class="btn secondary-btn" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;" title="Refresh stats">Refresh</button>
|
||||
<div class="settings-section-header">
|
||||
<h2>System Overview</h2>
|
||||
<div class="settings-header-actions">
|
||||
<span id="statsUpdatedAt" class="text-muted-xs"></span>
|
||||
<button id="refreshStatsBtn" class="btn secondary-btn btn-compact" title="Refresh stats">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid" style="grid-template-columns: repeat(5, 1fr);">
|
||||
<div class="stats-grid stats-grid-5">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="statTotalImages">-</span>
|
||||
<span class="stat-label">Total Images</span>
|
||||
@@ -60,7 +60,7 @@
|
||||
<p class="settings-description">Monitor and control the Celery-based import queue system.</p>
|
||||
|
||||
<div id="queueStatusSection">
|
||||
<div class="stats-grid" style="grid-template-columns: repeat(6, 1fr);">
|
||||
<div class="stats-grid stats-grid-6">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="queuePending">-</span>
|
||||
<span class="stat-label">Pending</span>
|
||||
@@ -88,14 +88,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Age breakdown tooltip -->
|
||||
<div id="ageBreakdown" style="display: none; margin-top: 0.5rem; padding: 0.5rem; background: rgba(255,255,255,0.05); border-radius: 4px; font-size: 0.8rem;">
|
||||
<span id="ageBreakdownTitle" style="color: var(--text-muted);"></span>:
|
||||
<div id="ageBreakdown" class="age-breakdown" style="display: none;">
|
||||
<span id="ageBreakdownTitle" class="text-muted"></span>:
|
||||
<span id="ageBreakdownToday">-</span> today,
|
||||
<span id="ageBreakdownWeek">-</span> this week,
|
||||
<span id="ageBreakdownOlder">-</span> older
|
||||
</div>
|
||||
|
||||
<div id="activeBatchInfo" class="import-stats" style="margin-top: 1rem; display: none;">
|
||||
<div id="activeBatchInfo" class="import-stats mt-md" style="display: none;">
|
||||
<h3>Active Batch</h3>
|
||||
<p>Processing <strong id="batchDir">-</strong></p>
|
||||
<div class="batch-progress">
|
||||
@@ -106,7 +106,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-actions" style="margin-top: 1rem; display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<div class="queue-actions">
|
||||
<button id="triggerImportBtn" class="btn primary-btn">Quick Scan</button>
|
||||
<button id="triggerDeepScanBtn" class="btn warning-btn" title="Full reprocessing: re-extracts metadata, runs pHash comparison, merges archive tags">Deep Scan</button>
|
||||
<button id="retryFailedBtn" class="btn warning-btn">Retry Failed Tasks</button>
|
||||
@@ -114,17 +114,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Task Cleanup Controls -->
|
||||
<div class="cleanup-controls" style="margin-top: 1rem; padding: 0.75rem; background: rgba(255,255,255,0.03); border-radius: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<span style="color: var(--text-muted); font-size: 0.85rem;">Clear tasks:</span>
|
||||
<select id="clearStatusSelect" class="form-input" style="width: auto; min-width: 140px;">
|
||||
<div class="cleanup-controls">
|
||||
<div class="cleanup-controls-row">
|
||||
<span class="text-muted-sm">Clear tasks:</span>
|
||||
<select id="clearStatusSelect" class="form-input select-compact">
|
||||
<option value="complete,skipped">Complete + Skipped</option>
|
||||
<option value="complete">Complete only</option>
|
||||
<option value="skipped">Skipped only</option>
|
||||
<option value="failed">Failed only</option>
|
||||
<option value="complete,skipped,failed">All finished</option>
|
||||
</select>
|
||||
<select id="clearAgeSelect" class="form-input" style="width: auto; min-width: 120px;">
|
||||
<select id="clearAgeSelect" class="form-input select-compact-sm">
|
||||
<option value="0">All</option>
|
||||
<option value="1">Older than 1 day</option>
|
||||
<option value="7" selected>Older than 7 days</option>
|
||||
@@ -132,17 +132,17 @@
|
||||
</select>
|
||||
<button id="clearTasksBtn" class="btn secondary-btn">Clear</button>
|
||||
</div>
|
||||
<p style="color: var(--text-muted); font-size: 0.75rem; margin-top: 0.5rem;">
|
||||
<p class="cleanup-controls-note">
|
||||
Note: Failed/skipped tasks older than 7 days are auto-cleaned daily.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Task List with Filters -->
|
||||
<div id="taskListSection" style="margin-top: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h3 style="margin: 0;">Import Tasks</h3>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<select id="taskStatusFilter" class="form-input" style="width: auto; min-width: 120px; padding: 0.4rem;">
|
||||
<div id="taskListSection" class="mt-lg">
|
||||
<div class="task-list-toolbar">
|
||||
<h3>Import Tasks</h3>
|
||||
<div class="task-list-filters">
|
||||
<select id="taskStatusFilter" class="form-input select-compact-sm">
|
||||
<option value="">All statuses</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
@@ -151,57 +151,57 @@
|
||||
<option value="queued">Queued</option>
|
||||
<option value="pending">Pending</option>
|
||||
</select>
|
||||
<input type="text" id="taskSearchInput" class="form-input" placeholder="Search..." style="width: 150px; padding: 0.4rem;">
|
||||
<button id="taskSearchBtn" class="btn secondary-btn" style="padding: 0.4rem 0.75rem;">Search</button>
|
||||
<input type="text" id="taskSearchInput" class="form-input task-list-search" placeholder="Search...">
|
||||
<button id="taskSearchBtn" class="btn secondary-btn btn-compact-md">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="taskListInfo" style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.5rem;">
|
||||
<div id="taskListInfo" class="task-list-info">
|
||||
Showing <span id="taskListShowing">0</span> of <span id="taskListTotal">0</span> tasks
|
||||
</div>
|
||||
|
||||
<div class="tasks-table-wrapper" style="max-height: 400px; overflow-y: auto;">
|
||||
<div class="tasks-table-wrapper">
|
||||
<table class="tasks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40%;">File</th>
|
||||
<th style="width: 10%;">Type</th>
|
||||
<th style="width: 10%;">Status</th>
|
||||
<th style="width: 30%;">Error/Reason</th>
|
||||
<th style="width: 10%;">Time</th>
|
||||
<th class="col-file">File</th>
|
||||
<th class="col-type">Type</th>
|
||||
<th class="col-status">Status</th>
|
||||
<th class="col-error">Error/Reason</th>
|
||||
<th class="col-time">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="taskListBody">
|
||||
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">Loading...</td></tr>
|
||||
<tr><td colspan="5" class="table-message-cell">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||
<div class="table-actions">
|
||||
<button id="loadMoreTasksBtn" class="btn secondary-btn" style="display: none;">Load More</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Detail Modal -->
|
||||
<div id="errorDetailModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<div class="modal-content error-detail-content">
|
||||
<div class="modal-header">
|
||||
<h3>Task Details</h3>
|
||||
<button class="modal-close" onclick="document.getElementById('errorDetailModal').style.display='none'">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div class="error-detail-row">
|
||||
<strong>File:</strong>
|
||||
<div id="errorDetailPath" style="word-break: break-all; font-family: monospace; font-size: 0.85rem; background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem;"></div>
|
||||
<div id="errorDetailPath" class="error-detail-path"></div>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div class="error-detail-row">
|
||||
<strong>Status:</strong> <span id="errorDetailStatus"></span>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div class="error-detail-row">
|
||||
<strong>Error/Reason:</strong>
|
||||
<div id="errorDetailMessage" style="word-break: break-word; font-family: monospace; font-size: 0.85rem; background: rgba(255,100,100,0.1); padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem; white-space: pre-wrap;"></div>
|
||||
<div id="errorDetailMessage" class="error-detail-message"></div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; font-size: 0.85rem; color: var(--text-muted);">
|
||||
<div class="error-detail-meta">
|
||||
<span><strong>Retries:</strong> <span id="errorDetailRetries"></span></span>
|
||||
<span><strong>Created:</strong> <span id="errorDetailCreated"></span></span>
|
||||
</div>
|
||||
@@ -265,7 +265,7 @@
|
||||
<p class="form-help">Controls how similar images must be to be considered duplicates.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn primary-btn" style="width: 100%;">Save Filter Settings</button>
|
||||
<button type="submit" class="btn primary-btn btn-fullwidth">Save Filter Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,12 +305,12 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn danger-btn" style="width: 100%;" disabled id="deleteByTagBtn">Delete Images</button>
|
||||
<button type="submit" class="btn danger-btn btn-fullwidth" disabled id="deleteByTagBtn">Delete Images</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-container" style="margin-top: 1rem;">
|
||||
<div class="settings-container mt-md">
|
||||
<div class="settings-section">
|
||||
<h2>Clean Up Existing Images</h2>
|
||||
<p class="settings-description">Scan and remove images that don't meet filter criteria.</p>
|
||||
@@ -343,10 +343,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="scanFilterBtn" class="btn primary-btn" style="width: 100%;">Scan for Matching Images</button>
|
||||
<button type="button" id="scanFilterBtn" class="btn primary-btn btn-fullwidth">Scan for Matching Images</button>
|
||||
</div>
|
||||
|
||||
<div id="cleanupResults" style="display: none; margin-top: 1rem;">
|
||||
<div id="cleanupResults" class="mt-md" style="display: none;">
|
||||
<div class="import-stats">
|
||||
<h3>Scan Results</h3>
|
||||
<p><strong id="cleanupCount">0</strong> images match the filter criteria.</p>
|
||||
@@ -354,27 +354,27 @@
|
||||
|
||||
<div id="cleanupPreview" class="cleanup-preview"></div>
|
||||
|
||||
<div id="cleanupActions" style="display: none; margin-top: 1rem;">
|
||||
<div id="cleanupActions" class="mt-md" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label class="form-label checkbox-label">
|
||||
<input type="checkbox" id="cleanupSelectAll">
|
||||
Select all for deletion
|
||||
</label>
|
||||
</div>
|
||||
<p class="form-help" style="margin-bottom: 0.75rem;">Selected: <strong id="cleanupSelectedCount">0</strong> images</p>
|
||||
<button type="button" id="deleteSelectedBtn" class="btn danger-btn" style="width: 100%;" disabled>Delete Selected Images</button>
|
||||
<p class="form-help mb-sm">Selected: <strong id="cleanupSelectedCount">0</strong> images</p>
|
||||
<button type="button" id="deleteSelectedBtn" class="btn danger-btn btn-fullwidth" disabled>Delete Selected Images</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duplicate Detection -->
|
||||
<div class="settings-container" style="margin-top: 1rem;">
|
||||
<div class="settings-container mt-md">
|
||||
<div class="settings-section">
|
||||
<h2>Duplicate Detection</h2>
|
||||
<p class="settings-description">Scan for visually similar images using perceptual hash comparison. The first image in each group has the highest resolution and will be kept.</p>
|
||||
|
||||
<div class="form-card" style="margin-bottom: 1rem;">
|
||||
<div class="form-card mb-md">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Hamming Distance Threshold</label>
|
||||
<select id="dupThreshold" class="form-input">
|
||||
@@ -386,21 +386,21 @@
|
||||
<p class="form-help">Lower values find only very similar images. Higher values find more potential duplicates but may include false positives.</p>
|
||||
</div>
|
||||
|
||||
<button type="button" id="scanDuplicatesBtn" class="btn primary-btn" style="width: 100%;">Scan for Duplicates</button>
|
||||
<button type="button" id="scanDuplicatesBtn" class="btn primary-btn btn-fullwidth">Scan for Duplicates</button>
|
||||
</div>
|
||||
|
||||
<div id="dupResults" style="display: none;">
|
||||
<div class="import-stats" style="margin-bottom: 1rem;">
|
||||
<div class="import-stats mb-md">
|
||||
<h3>Scan Results</h3>
|
||||
<p>Found <strong id="dupGroupCount">0</strong> group(s) of potential duplicates.</p>
|
||||
</div>
|
||||
|
||||
<div id="dupGroups"></div>
|
||||
|
||||
<div id="dupActions" style="margin-top: 1rem; display: none;">
|
||||
<p class="form-help" style="margin-bottom: 0.75rem;">
|
||||
<div id="dupActions" class="mt-md" style="display: none;">
|
||||
<p class="form-help mb-sm">
|
||||
Selected for deletion: <strong id="dupSelectedCount">0</strong> image(s)
|
||||
<span style="color: var(--text-muted);">(The highest resolution image in each group is protected)</span>
|
||||
<span class="text-muted">(The highest resolution image in each group is protected)</span>
|
||||
</p>
|
||||
<button type="button" id="deleteDuplicatesBtn" class="btn danger-btn" disabled>Delete Selected Duplicates</button>
|
||||
</div>
|
||||
@@ -423,14 +423,14 @@
|
||||
<h2>Maintenance Tools</h2>
|
||||
<p class="settings-description">Background operations and worker management.</p>
|
||||
|
||||
<div class="queue-actions" style="margin-top: 0.75rem; display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<div class="queue-actions-compact">
|
||||
<button id="regenMissingThumbsBtn" class="btn secondary-btn">Regenerate Missing Thumbnails</button>
|
||||
<button id="reapplyArtistTagsBtn" class="btn secondary-btn">Reapply Artist Tags from Paths</button>
|
||||
<button id="cleanupOrphanedTagsBtn" class="btn secondary-btn">Cleanup Orphaned Tags</button>
|
||||
</div>
|
||||
|
||||
<div id="celeryWorkerInfo" style="margin-top: 1rem;">
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">
|
||||
<div id="celeryWorkerInfo" class="mt-md">
|
||||
<p class="text-muted-sm">
|
||||
Workers: <strong id="workerCount">-</strong> |
|
||||
Active Tasks: <strong id="activeTasks">-</strong>
|
||||
</p>
|
||||
@@ -447,7 +447,7 @@
|
||||
<button type="submit" class="btn secondary-btn">Run ML backfill</button>
|
||||
</form>
|
||||
|
||||
<p class="settings-hint" style="margin-top: 1em;">
|
||||
<p class="settings-hint mt-paragraph">
|
||||
Recompute centroids for all eligible tags (character, fandom, and general/topic) that have at least
|
||||
the minimum number of reference images. Run this after the initial backfill, or any time you've
|
||||
applied many tags manually and want the suggestions to catch up.
|
||||
@@ -456,7 +456,7 @@
|
||||
<button type="submit" class="btn secondary-btn">Recompute all centroids</button>
|
||||
</form>
|
||||
|
||||
<p class="settings-hint" style="margin-top: 1em;">
|
||||
<p class="settings-hint mt-paragraph">
|
||||
Attach each character's fandom to every image already tagged with that character.
|
||||
Additive only — existing fandom tags are never removed. Run after you've set a
|
||||
fandom on an existing character, or whenever you suspect drift.
|
||||
@@ -810,6 +810,180 @@
|
||||
.tasks-table .error-cell:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* === Utility classes === */
|
||||
.mt-md { margin-top: 1rem; }
|
||||
.mt-sm { margin-top: 0.75rem; }
|
||||
.mt-lg { margin-top: 1.5rem; }
|
||||
.mt-paragraph { margin-top: 1em; }
|
||||
.mb-md { margin-bottom: 1rem; }
|
||||
.mb-sm { margin-bottom: 0.75rem; }
|
||||
|
||||
.text-muted-xs { font-size: 0.75rem; color: var(--text-muted); }
|
||||
.text-muted-sm { font-size: 0.85rem; color: var(--text-muted); }
|
||||
|
||||
.btn-compact {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: auto;
|
||||
}
|
||||
.btn-compact-md {
|
||||
padding: 0.4rem 0.75rem;
|
||||
min-width: auto;
|
||||
}
|
||||
.btn-fullwidth { width: 100%; min-width: 0; }
|
||||
|
||||
.select-compact {
|
||||
width: auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
.select-compact-sm {
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
/* === Section headers === */
|
||||
.settings-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.settings-section-header h2,
|
||||
.settings-section-header h3 {
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.settings-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* === Stats grid variants === */
|
||||
.stats-grid-5 { grid-template-columns: repeat(5, 1fr); }
|
||||
.stats-grid-6 { grid-template-columns: repeat(6, 1fr); }
|
||||
|
||||
/* === Age breakdown info panel === */
|
||||
.age-breakdown {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* === Import queue layout === */
|
||||
.queue-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.queue-actions-compact {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* === Cleanup controls === */
|
||||
.cleanup-controls {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.cleanup-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cleanup-controls-note {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* === Task list === */
|
||||
.task-list-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.task-list-toolbar h3 { margin: 0; }
|
||||
.task-list-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.task-list-search {
|
||||
width: 150px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.task-list-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tasks-table-wrapper {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tasks-table .col-file { width: 40%; }
|
||||
.tasks-table .col-type { width: 10%; }
|
||||
.tasks-table .col-status { width: 10%; }
|
||||
.tasks-table .col-error { width: 30%; }
|
||||
.tasks-table .col-time { width: 10%; }
|
||||
.table-message-cell {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.table-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* === Error detail modal === */
|
||||
.error-detail-content { max-width: 600px; }
|
||||
.error-detail-row { margin-bottom: 1rem; }
|
||||
.error-detail-path,
|
||||
.error-detail-message {
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.error-detail-path {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.error-detail-message {
|
||||
word-break: break-word;
|
||||
background: rgba(255, 100, 100, 0.1);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.error-detail-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === Empty state (used by JS-injected content) === */
|
||||
.empty-state-centered {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -894,7 +1068,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
</tr>
|
||||
`).join('');
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">No tasks found</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="table-message-cell">No tasks found</td></tr>';
|
||||
}
|
||||
|
||||
// Update info
|
||||
@@ -1578,7 +1752,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
resultsEl.style.display = 'block';
|
||||
|
||||
if (cleanupImages.length === 0) {
|
||||
previewEl.innerHTML = '<p style="text-align: center; color: var(--text-muted); padding: 1rem;">No images match the filter criteria.</p>';
|
||||
previewEl.innerHTML = '<p class="empty-state-centered">No images match the filter criteria.</p>';
|
||||
actionsEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -1737,7 +1911,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
resultsEl.style.display = 'block';
|
||||
|
||||
if (duplicateGroups.length === 0) {
|
||||
groupsEl.innerHTML = '<p style="text-align: center; color: var(--text-muted); padding: 1rem;">No duplicate images found.</p>';
|
||||
groupsEl.innerHTML = '<p class="empty-state-centered">No duplicate images found.</p>';
|
||||
actionsEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"thumb_url": "{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '')) }}",
|
||||
"full_url": "{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}",
|
||||
"is_video": {{ 'true' if image.filename.lower().endswith(('.mp4', '.mov')) else 'false' }},
|
||||
"width": {{ image.width if image.width else 'null' }},
|
||||
"height": {{ image.height if image.height else 'null' }},
|
||||
"tags": [{% for t in image.tags %}{"name": "{{ t.name | e }}", "kind": "{{ t.kind }}"}{% if not loop.last %}, {% endif %}{% endfor %}]
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}]
|
||||
|
||||
Reference in New Issue
Block a user