feat(ui): "Reject rest" per suggestion category — confirm the good, reject the rest

Each category section header gets a subtle "Reject rest" action that dismisses
every still-unhandled suggestion in it at once (store.dismissRemaining, parallel
dispatch). Canonical tags persist a rejection and stay flagged (reversible,
one-click un-reject); raw creates-new-tag rows drop client-side. Shows only when
the section has unhandled items. No confirm dialog — it's fully reversible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-06 18:42:08 -04:00
parent 2d44a26bdf
commit 6684907577
3 changed files with 83 additions and 15 deletions
@@ -1,14 +1,25 @@
<template> <template>
<div class="fc-sgroup"> <div class="fc-sgroup">
<button <div class="fc-sgroup__header-row">
v-if="collapsible" <button
class="fc-sgroup__header fc-sgroup__header--btn" v-if="collapsible"
@click="open = !open" class="fc-sgroup__header fc-sgroup__header--btn"
> @click="open = !open"
<v-icon size="small">{{ open ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon> >
{{ label }} ({{ items.length }}) <v-icon size="small">{{ open ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
</button> {{ label }} ({{ items.length }})
<div v-else class="fc-sgroup__header">{{ label }}</div> </button>
<div v-else class="fc-sgroup__header">{{ label }}</div>
<!-- "Confirm what's right, reject the rest": clears every still-unhandled
suggestion in this section at once. Reversible (each stays flagged
rejected with one-click un-reject), so no confirm dialog. -->
<button
v-if="rejectableCount > 0"
class="fc-sgroup__reject-rest" type="button"
:title="`Reject the ${rejectableCount} remaining ${label} suggestion${rejectableCount === 1 ? '' : 's'}`"
@click="$emit('reject-all')"
>Reject rest</button>
</div>
<div v-show="open" class="fc-sgroup__items"> <div v-show="open" class="fc-sgroup__items">
<SuggestionItem <SuggestionItem
@@ -25,7 +36,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import SuggestionItem from './SuggestionItem.vue' import SuggestionItem from './SuggestionItem.vue'
const props = defineProps({ const props = defineProps({
@@ -34,24 +45,45 @@ const props = defineProps({
collapsible: { type: Boolean, default: false }, collapsible: { type: Boolean, default: false },
defaultOpen: { type: Boolean, default: true } defaultOpen: { type: Boolean, default: true }
}) })
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss']) defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss', 'reject-all'])
// Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length)
const open = ref(props.collapsible ? props.defaultOpen : true) const open = ref(props.collapsible ? props.defaultOpen : true)
</script> </script>
<style scoped> <style scoped>
.fc-sgroup { margin-bottom: 10px; } .fc-sgroup { margin-bottom: 10px; }
.fc-sgroup__header-row {
display: flex; align-items: center; gap: 8px;
margin-bottom: 4px;
}
.fc-sgroup__header { .fc-sgroup__header {
flex: 1 1 auto; min-width: 0;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 11px; font-weight: 600; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface))); color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
margin-bottom: 4px;
} }
.fc-sgroup__header--btn { .fc-sgroup__header--btn {
display: flex; align-items: center; gap: 4px; display: flex; align-items: center; gap: 4px;
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
padding: 0; width: 100%; text-align: left; padding: 0; text-align: left;
font: inherit; text-transform: uppercase; letter-spacing: 0.06em; font: inherit; text-transform: uppercase; letter-spacing: 0.06em;
} }
/* Section-level "reject the remaining" — subtle until hovered so it doesn't
compete with the per-row verdicts. */
.fc-sgroup__reject-rest {
flex: 0 0 auto;
background: none; border: none; cursor: pointer;
font-family: 'Inter', sans-serif;
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-error));
padding: 2px 5px; border-radius: 4px;
}
.fc-sgroup__reject-rest:hover { background: rgb(var(--v-theme-error), 0.1); }
.fc-sgroup__reject-rest:focus-visible {
outline: 2px solid rgb(var(--v-theme-error)); outline-offset: 1px;
}
</style> </style>
@@ -25,6 +25,7 @@
collapsible :default-open="true" collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias" @accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('system')"
/> />
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-for="cat in peopleCats" :key="cat" v-for="cat in peopleCats" :key="cat"
@@ -32,6 +33,7 @@
:label="labelFor(cat)" :items="store.byCategory[cat] || []" :label="labelFor(cat)" :items="store.byCategory[cat] || []"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias" @accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll(cat)"
/> />
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-if="store.byCategory.general && store.byCategory.general.length" v-if="store.byCategory.general && store.byCategory.general.length"
@@ -39,6 +41,7 @@
collapsible :default-open="true" collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias" @accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('general')"
/> />
</div> </div>
@@ -76,6 +79,16 @@ const emit = defineEmits(['accepted', 'dismissed'])
// re-focus the tag input — same return-to-input behaviour as accept. // re-focus the tag input — same return-to-input behaviour as accept.
function onDismiss (s) { store.dismiss(s); emit('dismissed') } function onDismiss (s) { store.dismiss(s); emit('dismissed') }
function onUndismiss (s) { store.undismiss(s); emit('dismissed') } function onUndismiss (s) { store.undismiss(s); emit('dismissed') }
// Section-level "Reject rest": dismiss every still-unhandled suggestion in the
// category, then return focus to the input like a single reject does.
async function onRejectAll (category) {
try {
await store.dismissRemaining(category)
emit('dismissed')
} catch (e) {
toast({ text: `Reject failed: ${e.message}`, type: 'error' })
}
}
const store = useSuggestionsStore() const store = useSuggestionsStore()
const modalStore = useModalStore() const modalStore = useModalStore()
const host = props.host || modalStore const host = props.host || modalStore
+25 -2
View File
@@ -218,6 +218,29 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
} }
// Reject every still-unhandled suggestion in a category in one go ("confirm
// the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags
// persist a rejection and STAY flagged rejected (reversible, one-click
// un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel
// so a big section clears fast.
async function dismissRemaining(category) {
const imageId = currentImageId
if (imageId == null) return
const targets = (byCategory.value[category] || []).filter((s) => !s.rejected)
if (!targets.length) return
const canon = targets.filter((s) => s.canonical_tag_id != null)
const raw = targets.filter((s) => s.canonical_tag_id == null)
await Promise.all(canon.map((s) =>
api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: s.canonical_tag_id },
})
))
if (currentImageId === imageId) {
canon.forEach((s) => _setRejectedEverywhere(s, true))
raw.forEach((s) => _dropEverywhere(s))
}
}
// Undo a per-image dismissal — the suggestion reverts to a live row. // Undo a per-image dismissal — the suggestion reverts to a live row.
async function undismiss(suggestion) { async function undismiss(suggestion) {
const imageId = currentImageId const imageId = currentImageId
@@ -232,7 +255,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
return { return {
byCategory, allByCategory, loading, error, byCategory, allByCategory, loading, error,
load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss, load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining,
findPending undismiss, findPending
} }
}) })