Files
FabledCurator/frontend/src/components/modal/SuggestionsPanel.vue
T
bvandeusen af7b5c95e9
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 17s
CI / frontend-build (push) Successful in 18s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m46s
CI / intcore (push) Failing after 8m18s
feat(modal): autofocus tag input, expand general suggestions, retire copyright/artist categories
Four coupled operator-asked changes to the view modal (Scribe plan #509):

1. **Autofocus tag entry on modal open** — TagAutocomplete grabs focus
   in onMounted/nextTick so the caret is in the input the moment the
   modal renders. No click needed to start typing.

2. **General suggestions expanded by default** — SuggestionsPanel's
   general-category group now mounts with `:default-open="true"`.
   Operator can collapse if too noisy, but the v1 frame shows them.

3. **Lower general threshold default 0.95 → 0.50** — MLSettings.
   suggestion_threshold_general default matches character. Alembic
   0029 also bumps the existing singleton row's value if it's still
   at the old 0.95. Operator can re-tune from Settings → ML.

4. **Retire `copyright` + `artist` as ML suggestion categories** —
   neither feeds a Tag.kind (`artist` retired in FC-2d-vii-c, never
   really existed as a copyright tag-kind). They were surfaced in the
   suggestions pipeline + threshold settings UI but had no follow-
   through. Drop from SURFACED_CATEGORIES, suggestions._threshold_for,
   ml_admin GET/PATCH allowlist, MLSettings columns (alembic 0029
   drops the two columns), frontend CATEGORY_ORDER + CATEGORY_LABELS,
   SuggestionsPanel.peopleCats, AliasPickerDialog kind-check, and
   MLThresholdSliders rows.

Out of scope (intentional): `tag_kind` Postgres enum still includes
`artist` for historic Tag row queryability (per the model comment);
no operator pain reported, no enum-shrink needed.

Tests:
- test_surfaced_categories asserts {character, general}, excludes
  artist + copyright.
- test_threshold_for_artist_is_unsurfaced extended to cover copyright.
- test_get_and_patch_settings asserts new 0.50 default and the absent
  artist + copyright keys in the GET payload.
2026-06-01 02:08:10 -04:00

109 lines
3.5 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>
<template v-else>
<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" @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" @dismiss="store.dismiss"
/>
</template>
<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 SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
import AliasPickerDialog from './AliasPickerDialog.vue'
const props = defineProps({ imageId: { type: Number, required: true } })
const store = useSuggestionsStore()
// '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) store.load(id) }, { immediate: true })
async function onAccept(s) {
try { await store.accept(s) }
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
} catch (e) {
toast({ text: `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__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>