Files
FabledCurator/frontend/src/components/modal/SuggestionsPanel.vue
T
bvandeusen 179c1a9dcc
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Failing after 3m19s
feat(suggestions): visible, reversible rejection in the modal rail
A red-✗ dismissal no longer makes the suggestion vanish. The rejected tag
stays in the rail — dimmed, struck-through, with a "rejected" pill and a
one-click undo (↶) in place of the ✗ — so a misclick is recoverable and the
operator can see what they've said no to (operator-asked 2026-06-27).

Backend: SuggestionService.for_image now KEEPS rejected tags, flagged
rejected=True, sorted to the bottom of their category, instead of dropping
them. New AllowlistService.undismiss + POST /suggestions/undismiss clears the
TagSuggestionRejection. Rejected items are still excluded from bulk consensus
(for_selection) and the type-to-add dropdown, whose jobs are unchanged.

Frontend: store.dismiss flags in place (canonical tags) rather than dropping;
new store.undismiss reverts. SuggestionItem renders the rejected state and
swaps ✗→↶; ✓ still accepts (which clears the rejection server-side).

Tests: rejected-surfaced-flagged-then-reversible (service) + undismiss
endpoint idempotency (API).

Completes #1134's reversible-rejection half. Heads-as-suggestion-source is
the remaining piece.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-28 09:49:05 -04:00

161 lines
5.8 KiB
Vue

<template>
<section class="fc-suggestions" aria-label="Tag suggestions">
<h3 class="fc-suggestions__title">Suggestions</h3>
<div v-if="store.loading" class="fc-suggestions__skeleton">
<div v-for="i in 4" :key="i" class="fc-suggestions__skel-row" />
</div>
<v-alert v-else-if="store.error" type="error" variant="tonal" density="compact">
{{ store.error }}
</v-alert>
<div v-else-if="isEmpty" class="text-caption">
No suggestions above threshold.
</div>
<!-- Flows in the rail's main scroll area; Related is pinned to the bottom
of the rail (ImageViewer side layout), so a long suggestion set no
longer needs an internal scroll cap to keep Related reachable. -->
<div v-else class="fc-suggestions__list">
<SuggestionsCategoryGroup
v-for="cat in peopleCats" :key="cat"
v-show="store.byCategory[cat] && store.byCategory[cat].length"
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="store.dismiss" @undismiss="store.undismiss"
/>
<SuggestionsCategoryGroup
v-if="store.byCategory.general && store.byCategory.general.length"
label="General" :items="store.byCategory.general"
collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="store.dismiss" @undismiss="store.undismiss"
/>
</div>
<v-dialog v-model="aliasDialog" max-width="480">
<AliasPickerDialog
v-if="aliasTarget"
:category="aliasTarget.category"
@confirm="onAliasConfirm" @cancel="aliasDialog = false"
/>
</v-dialog>
</section>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref, watch } from 'vue'
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
import { useModalStore } from '../../stores/modal.js'
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
import AliasPickerDialog from './AliasPickerDialog.vue'
const props = defineProps({
imageId: { type: Number, required: true },
// The tagging host whose chip rail to refresh after an accept. Defaults to
// the modal store (image modal); the Explore workspace passes its anchor host
// so the same panel refreshes the right surface. See TagPanel.
host: { type: Object, default: null },
})
// 'accepted' lets the parent return focus to the tag input after a suggestion is
// applied (operator-asked 2026-06-08).
const emit = defineEmits(['accepted'])
const store = useSuggestionsStore()
const modalStore = useModalStore()
const host = props.host || modalStore
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
// suggestion categories. Only 'character' remains as a people-style
// category alongside the general bucket.
const peopleCats = ['character']
function labelFor(c) { return CATEGORY_LABELS[c] || c }
const isEmpty = computed(() =>
Object.values(store.byCategory).every(list => !list || list.length === 0)
)
watch(() => props.imageId, (id) => {
if (id == null) return
store.load(id) // panel: curated, ≥ threshold
store.loadAll(id) // dropdown: full prediction set (low-confidence included)
}, { immediate: true })
// After a successful accept/alias-accept, refresh the modal's current
// tag list so TagPanel's chip rail reflects the newly-attached tag.
// Operator-flagged 2026-06-01: the suggestion store dropped the
// suggestion (correct) but didn't propagate the new tag back into the
// modal store, so the chip rail looked unchanged even though the
// backend had recorded the application. Mirrors the addExistingTag /
// createAndAdd flows which already call reloadTags() after applying.
async function onAccept(s) {
try {
await store.accept(s)
await host.reloadTags()
emit('accepted')
} catch (e) {
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
}
}
const aliasDialog = ref(false)
const aliasTarget = ref(null)
function onAlias(s) { aliasTarget.value = s; aliasDialog.value = true }
async function onAliasConfirm(canonicalTagId) {
try {
await store.aliasAccept(aliasTarget.value, canonicalTagId)
aliasDialog.value = false
await host.reloadTags()
emit('accepted')
} catch (e) {
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
}
}
// Undo the model-key→tag mapping behind an aliased suggestion. The store
// reloads suggestions so the prediction reverts to its raw form; the applied
// canonical tag (if any) stays, so no tag-rail reload is needed.
async function onRemoveAlias(s) {
try {
await store.removeAlias(s)
} catch (e) {
toast({ text: `Remove alias failed: ${e.message}`, type: 'error' })
}
}
</script>
<style scoped>
.fc-suggestions {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgb(var(--v-theme-surface-light));
}
.fc-suggestions__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px; font-weight: 500;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 8px;
}
.fc-suggestions__list {
/* No internal scroll cap: the rail now scrolls suggestions in its single
main scroll area while Related is pinned to the bottom (ImageViewer side
layout), so suggestions flow naturally without a nested scrollbar. */
padding-right: 4px;
}
.fc-suggestions__skeleton { display: flex; flex-direction: column; gap: 8px; }
.fc-suggestions__skel-row {
height: 18px; border-radius: 4px;
background: linear-gradient(
90deg,
rgb(var(--v-theme-surface)) 0%,
rgb(var(--v-theme-surface-light)) 50%,
rgb(var(--v-theme-surface)) 100%
);
background-size: 200% 100%;
animation: fc-shimmer 1.4s infinite;
}
@keyframes fc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>