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:
@@ -838,11 +838,67 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (j.ok) {
|
if (j.ok) {
|
||||||
await loadTags(imageId);
|
await loadTags(imageId);
|
||||||
showFeedback(tagActionFeedback, 'Tag removed');
|
showFeedback(tagActionFeedback, 'Tag removed');
|
||||||
|
showBlocklistOffer(name);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
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
|
// Fandom picker helpers
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
@@ -1155,6 +1211,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
setEditorImageId(imageId);
|
setEditorImageId(imageId);
|
||||||
if (tagInput) tagInput.value = '';
|
if (tagInput) tagInput.value = '';
|
||||||
clearFandomPicker();
|
clearFandomPicker();
|
||||||
|
hideBlocklistOffer();
|
||||||
loadTags(imageId);
|
loadTags(imageId);
|
||||||
loadSeriesInfo(imageId);
|
loadSeriesInfo(imageId);
|
||||||
// Prefill the reserved autocomplete area with top tags so it's never empty.
|
// Prefill the reserved autocomplete area with top tags so it's never empty.
|
||||||
@@ -1175,6 +1232,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
clearFandomPicker();
|
clearFandomPicker();
|
||||||
|
hideBlocklistOffer();
|
||||||
// Get the image ID before clearing so we can refresh its tags
|
// Get the image ID before clearing so we can refresh its tags
|
||||||
const imageId = getEditorImageId();
|
const imageId = getEditorImageId();
|
||||||
|
|
||||||
|
|||||||
@@ -1020,6 +1020,44 @@ header {
|
|||||||
border-color: rgba(255, 180, 0, 0.6);
|
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) */
|
/* Auto-accepted suggestion block (top of suggestions section) */
|
||||||
.suggestions-auto-accepted {
|
.suggestions-auto-accepted {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
|||||||
@@ -73,6 +73,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="tagAutocomplete" class="tag-autocomplete tag-autocomplete-inline" aria-live="polite"></div>
|
<div id="tagAutocomplete" class="tag-autocomplete tag-autocomplete-inline" aria-live="polite"></div>
|
||||||
<span id="tagActionFeedback" class="tag-feedback tag-feedback-inline"></span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- SECTION: Suggestions (ML-backed, modal-only) -->
|
<!-- SECTION: Suggestions (ML-backed, modal-only) -->
|
||||||
|
|||||||
Reference in New Issue
Block a user