feat(modal): inline blocklist offer after pill × removal

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 20:54:19 -04:00
parent 4b84076540
commit e76e94a635
3 changed files with 101 additions and 0 deletions
+58
View File
@@ -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();
+38
View File
@@ -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;
+5
View File
@@ -73,6 +73,11 @@
</div>
<div id="tagAutocomplete" class="tag-autocomplete tag-autocomplete-inline" aria-live="polite"></div>
<span id="tagActionFeedback" class="tag-feedback tag-feedback-inline"></span>
<div id="blocklistOffer" class="blocklist-offer" aria-hidden="true">
<span class="blocklist-offer-label"></span>
<button type="button" class="btn-small blocklist-offer-btn">⊘ Blocklist</button>
<button type="button" class="btn-small blocklist-offer-dismiss" aria-label="Dismiss">×</button>
</div>
</div>
<!-- SECTION: Suggestions (ML-backed, modal-only) -->