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:
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-suggestion">
|
||||||
|
<span class="fc-suggestion__name">
|
||||||
|
{{ suggestion.display_name }}
|
||||||
|
<span v-if="suggestion.creates_new_tag" class="fc-suggestion__new"
|
||||||
|
title="No matching tag yet — accepting creates it">+new</span>
|
||||||
|
</span>
|
||||||
|
<span class="fc-suggestion__score">{{ scorePct }}</span>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-plus" size="x-small" variant="text" color="accent"
|
||||||
|
:aria-label="`Accept ${suggestion.display_name}`"
|
||||||
|
@click="$emit('accept', suggestion)"
|
||||||
|
/>
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn icon="mdi-dots-vertical" size="x-small" variant="text" v-bind="props" />
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item @click="$emit('alias', suggestion)">
|
||||||
|
<v-list-item-title>Treat as alias for…</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item @click="$emit('dismiss', suggestion)">
|
||||||
|
<v-list-item-title>Dismiss for this image</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({ suggestion: { type: Object, required: true } })
|
||||||
|
defineEmits(['accept', 'alias', 'dismiss'])
|
||||||
|
|
||||||
|
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-suggestion {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.fc-suggestion__name { flex: 1; min-width: 0; }
|
||||||
|
.fc-suggestion__new {
|
||||||
|
font-size: 10px; color: rgb(var(--v-theme-accent));
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.fc-suggestion__score {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<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 v-show="open" class="fc-sgroup__items">
|
||||||
|
<SuggestionItem
|
||||||
|
v-for="(s, i) in items" :key="`${s.display_name}-${i}`"
|
||||||
|
:suggestion="s"
|
||||||
|
@accept="$emit('accept', $event)"
|
||||||
|
@alias="$emit('alias', $event)"
|
||||||
|
@dismiss="$emit('dismiss', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SuggestionItem from './SuggestionItem.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: { type: String, required: true },
|
||||||
|
items: { type: Array, required: true },
|
||||||
|
collapsible: { type: Boolean, default: false },
|
||||||
|
defaultOpen: { type: Boolean, default: true }
|
||||||
|
})
|
||||||
|
defineEmits(['accept', 'alias', 'dismiss'])
|
||||||
|
|
||||||
|
const open = ref(props.collapsible ? props.defaultOpen : true)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-sgroup { margin-bottom: 10px; }
|
||||||
|
.fc-sgroup__header {
|
||||||
|
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;
|
||||||
|
font: inherit; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<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="false"
|
||||||
|
@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 { 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()
|
||||||
|
|
||||||
|
const peopleCats = ['artist', 'character', 'copyright']
|
||||||
|
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) { window.__fcToast?.({ 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) {
|
||||||
|
window.__fcToast?.({ 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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user