feat(fc2b): suggestions store + modal panel components

Store: load per-image suggestions, accept (creates the tag first for
raw/creates_new_tag suggestions, then accepts by id), aliasAccept,
dismiss (client-side hide for raw tags), success toasts folded in.
Panel: people/sources groups always open, General collapsed by default;
alias picker dialog wired; shimmer skeleton while loading. SuggestionItem
shows score %, +new badge for raw tags, kebab menu. AliasPickerDialog
referenced (lands in Task 15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 07:55:10 -04:00
parent aa4cc7c629
commit a8cc6a27dc
4 changed files with 307 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
// Category display order: people/sources first, general last.
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general']
export const CATEGORY_LABELS = {
artist: 'Artist',
character: 'Character',
copyright: 'Copyright',
general: 'General'
}
export const useSuggestionsStore = defineStore('suggestions', () => {
const api = useApi()
const byCategory = ref({}) // { category: [suggestion, ...] }
const loading = ref(false)
const error = ref(null)
let currentImageId = null
async function load(imageId) {
currentImageId = imageId
loading.value = true
error.value = null
try {
const body = await api.get(`/api/images/${imageId}/suggestions`)
byCategory.value = body.by_category || {}
} catch (e) {
error.value = e.message
byCategory.value = {}
} finally {
loading.value = false
}
}
function _drop(category, predicate) {
const list = byCategory.value[category]
if (!list) return
byCategory.value[category] = list.filter(s => !predicate(s))
}
async function accept(suggestion) {
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
// accept endpoint needs a tag_id, so for raw tags we create the tag
// first via the existing /api/tags endpoint, then accept by id.
let tagId = suggestion.canonical_tag_id
if (tagId == null) {
const created = await api.post('/api/tags', {
body: { name: suggestion.display_name, kind: suggestion.category }
})
tagId = created.id
}
await api.post(`/api/images/${currentImageId}/suggestions/accept`, {
body: { tag_id: tagId }
})
_drop(suggestion.category, s => s === suggestion)
window.__fcToast?.({
text: `Tagged: ${suggestion.display_name}`,
type: 'success'
})
}
async function aliasAccept(suggestion, canonicalTagId) {
await api.post(`/api/images/${currentImageId}/suggestions/alias`, {
body: {
alias_string: suggestion.display_name,
alias_category: suggestion.category,
canonical_tag_id: canonicalTagId
}
})
_drop(suggestion.category, s => s === suggestion)
window.__fcToast?.({
text: `Aliased & tagged: ${suggestion.display_name}`,
type: 'success'
})
}
async function dismiss(suggestion) {
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw
// suggestion just hides it client-side (nothing to persist a rejection
// against until the tag exists).
if (suggestion.canonical_tag_id != null) {
await api.post(`/api/images/${currentImageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id }
})
}
_drop(suggestion.category, s => s === suggestion)
}
return {
byCategory, loading, error,
load, accept, aliasAccept, dismiss
}
})