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,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>
|
||||
Reference in New Issue
Block a user