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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user