Files
FabledCurator/frontend/src/components/modal/SuggestionsPanel.vue
T
bvandeusen 3fcc4aeb43
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m23s
fix(modal): scroll-cap the suggestions list so Related stays reachable
The right rail scrolls as a whole and ProvenancePanel already caps its cards +
attachments, but SuggestionsPanel had no cap — a long suggestion set (the
General bucket runs to dozens) stretched the rail and pushed the Related strip
below the fold. Wrap the suggestion groups in a 320px max-height scroll box
(hairline scrollbar matching the provenance regions), so suggestions scroll
internally and Related stays visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
2026-06-24 00:01:25 -04:00

159 lines
5.6 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>
<!-- Scroll-capped so a long suggestion set (the General bucket can run to
dozens) scrolls WITHIN this box instead of stretching the right rail
and shoving the Related strip below the fold. Mirrors the
ProvenancePanel cards/attachments treatment. -->
<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"
/>
<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"
/>
</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 } })
// '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 modal = useModalStore()
// '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 modal.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 modal.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 {
/* Cap the suggestion run so it scrolls internally rather than pushing the
Related strip off the bottom of the rail. Hairline scrollbar matching
ProvenancePanel's capped regions. */
max-height: 320px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
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>