From e76e94a63544f7dae1f11e0386c4df39a6a402ef Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 20:54:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(modal):=20inline=20blocklist=20offer=20aft?= =?UTF-8?q?er=20pill=20=C3=97=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user clicks the × on a tag pill, the tag is removed from the image and a small '/Removed 'X' — [⊘ Blocklist] [×]/' control surfaces below the tag input. One click on ⊘ Blocklist escalates the removal to a global blocklist add (which also enqueues the retroactive sweep Celery task, removing the tag from every other image that has it). Fades after 8s, hides on image switch or modal close. Targets the composition-descriptor case: WD14 tags like '1girl', 'breasts', 'simple background' that show up on many images. Without this, after removing the pill the user has to navigate to Settings → Maintenance → Suggestion blocklist to add the name, or wait for the next suggestion chip to appear and use its ⊘. Now it's one click at the point of removal. Misclick recovery unchanged: the pill × alone only rejects per-image. The inline offer is opt-in; ignore it or click × to dismiss and the removal stays scoped to the current image only. Co-Authored-By: Claude Opus 4.7 --- app/static/js/view-modal.js | 58 +++++++++++++++++++++++++++++++ app/static/style.css | 38 ++++++++++++++++++++ app/templates/_gallery_modal.html | 5 +++ 3 files changed, 101 insertions(+) diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index f293371..5a4ceff 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -838,11 +838,67 @@ document.addEventListener('DOMContentLoaded', () => { if (j.ok) { await loadTags(imageId); showFeedback(tagActionFeedback, 'Tag removed'); + showBlocklistOffer(name); return true; } return false; } + // After a pill removal, offer an inline "Blocklist this everywhere" button. + // Fades after 8s; click escalates to blocklist (same endpoint + Celery sweep + // as the suggestion chip's ⊘ button). Covers the case where you remove a + // WD14 composition descriptor (e.g. '1girl', 'breasts') from this image and + // realize you also don't want it anywhere else. + function showBlocklistOffer(name) { + const offer = document.getElementById('blocklistOffer'); + if (!offer) return; + const label = offer.querySelector('.blocklist-offer-label'); + const btn = offer.querySelector('.blocklist-offer-btn'); + const dismiss = offer.querySelector('.blocklist-offer-dismiss'); + if (!label || !btn) return; + + label.textContent = `Removed '${name}' — also blocklist?`; + offer.dataset.name = name; + offer.classList.add('visible'); + offer.setAttribute('aria-hidden', 'false'); + btn.disabled = false; + + clearTimeout(offer._fadeTimeout); + offer._fadeTimeout = setTimeout(() => hideBlocklistOffer(), 8000); + + btn.onclick = async () => { + btn.disabled = true; + try { + const r = await fetch('/api/suggestions/blocklist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const j = await r.json(); + if (j.ok) { + label.textContent = `Blocklisted '${name}'. Cleanup task enqueued.`; + setTimeout(() => hideBlocklistOffer(), 2500); + } else { + label.textContent = `Couldn't blocklist: ${j.error || 'error'}`; + btn.disabled = false; + } + } catch (e) { + label.textContent = `Couldn't blocklist: ${e}`; + btn.disabled = false; + } + }; + + if (dismiss) dismiss.onclick = () => hideBlocklistOffer(); + } + + function hideBlocklistOffer() { + const offer = document.getElementById('blocklistOffer'); + if (!offer) return; + offer.classList.remove('visible'); + offer.setAttribute('aria-hidden', 'true'); + clearTimeout(offer._fadeTimeout); + } + // --------------------------- // Fandom picker helpers // --------------------------- @@ -1155,6 +1211,7 @@ document.addEventListener('DOMContentLoaded', () => { setEditorImageId(imageId); if (tagInput) tagInput.value = ''; clearFandomPicker(); + hideBlocklistOffer(); loadTags(imageId); loadSeriesInfo(imageId); // Prefill the reserved autocomplete area with top tags so it's never empty. @@ -1175,6 +1232,7 @@ document.addEventListener('DOMContentLoaded', () => { function closeModal() { clearFandomPicker(); + hideBlocklistOffer(); // Get the image ID before clearing so we can refresh its tags const imageId = getEditorImageId(); diff --git a/app/static/style.css b/app/static/style.css index a93a5d9..c33cf8a 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1020,6 +1020,44 @@ header { border-color: rgba(255, 180, 0, 0.6); } +/* Inline blocklist offer shown after a pill-× removal. Fades after 8s or + on image/modal change; click escalates to blocklist + sweep. */ +.blocklist-offer { + display: none; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; + padding: 0.4rem 0.6rem; + background: rgba(255, 180, 0, 0.08); + border: 1px solid rgba(255, 180, 0, 0.3); + border-radius: 6px; + font-size: 0.85rem; + color: var(--text); +} +.blocklist-offer.visible { + display: flex; +} +.blocklist-offer-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #ffb400; +} +.blocklist-offer-btn { + color: #ffb400; + border-color: rgba(255, 180, 0, 0.5); +} +.blocklist-offer-btn:hover { + background: rgba(255, 180, 0, 0.15); +} +.blocklist-offer-dismiss { + color: var(--text-muted); + padding: 0 0.4rem; +} + /* Auto-accepted suggestion block (top of suggestions section) */ .suggestions-auto-accepted { margin-bottom: 0.75rem; diff --git a/app/templates/_gallery_modal.html b/app/templates/_gallery_modal.html index 4815a75..b7a3f0b 100644 --- a/app/templates/_gallery_modal.html +++ b/app/templates/_gallery_modal.html @@ -73,6 +73,11 @@
+