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>
|
||||
<div class="fc-sgroup">
|
||||
<button
|
||||
v-if="collapsible"
|
||||
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 }})
|
||||
</button>
|
||||
<div v-else class="fc-sgroup__header">{{ label }}</div>
|
||||
<div class="fc-sgroup__header-row">
|
||||
<button
|
||||
v-if="collapsible"
|
||||
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 }})
|
||||
</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">
|
||||
<SuggestionItem
|
||||
@@ -25,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import SuggestionItem from './SuggestionItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -34,24 +45,45 @@ const props = defineProps({
|
||||
collapsible: { type: Boolean, default: false },
|
||||
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)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-sgroup { margin-bottom: 10px; }
|
||||
.fc-sgroup__header-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.fc-sgroup__header {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.fc-sgroup__header--btn {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
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;
|
||||
}
|
||||
/* 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>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll('system')"
|
||||
/>
|
||||
<SuggestionsCategoryGroup
|
||||
v-for="cat in peopleCats" :key="cat"
|
||||
@@ -32,6 +33,7 @@
|
||||
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll(cat)"
|
||||
/>
|
||||
<SuggestionsCategoryGroup
|
||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
||||
@@ -39,6 +41,7 @@
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
|
||||
@dismiss="onDismiss" @undismiss="onUndismiss"
|
||||
@reject-all="onRejectAll('general')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +79,16 @@ const emit = defineEmits(['accepted', 'dismissed'])
|
||||
// re-focus the tag input — same return-to-input behaviour as accept.
|
||||
function onDismiss (s) { store.dismiss(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 modalStore = useModalStore()
|
||||
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.
|
||||
async function undismiss(suggestion) {
|
||||
const imageId = currentImageId
|
||||
@@ -232,7 +255,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
|
||||
return {
|
||||
byCategory, allByCategory, loading, error,
|
||||
load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss,
|
||||
findPending
|
||||
load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining,
|
||||
undismiss, findPending
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user