feat(tagging): allowlist tuning dashboard + post-accept toast + merge preview UI (#7c/#7d/#8b)
Cluster B frontend, milestone #99. #7c: AllowlistTable gains Applied + Covers columns and a live 'covers ~N at T' projection as the operator drags a row's threshold (debounced coverage call, then commits the threshold). allowlist store gains coverage(tagId, threshold) and refreshes coverage_count after a save. #7d: suggestions store surfaces a non-blocking toast when accept/alias newly allowlists a tag — '<verb>: <tag> — allowlisted, auto-applying to ~N images' (N is the projection; apply runs async). Falls back to the plain toast when the tag was already allowlisted. #8b: TagsView merge picker now previews the merge via usePreviewCommit before committing — shows images moving / already-on-target / series pages / alias-or- delete / a thumbnail sample, blocks the Merge button on an incompatible kind/fandom. adminStore.mergeTags gains a dryRun option. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
@@ -65,10 +65,11 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
return _guard(() => api.delete(`/api/admin/tags/${tagId}`))
|
||||
}
|
||||
|
||||
function mergeTags(destId, sourceId) {
|
||||
// dryRun → {preview: {...}} (non-mutating counts + sample); else {result}.
|
||||
function mergeTags(destId, sourceId, { dryRun = false } = {}) {
|
||||
return _guard(() => api.post(
|
||||
`/api/admin/tags/${destId}/merge`,
|
||||
{ body: { source_id: sourceId } },
|
||||
{ body: { source_id: sourceId, dry_run: dryRun } },
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,21 @@ export const useAllowlistStore = defineStore('allowlist', () => {
|
||||
body: { min_confidence: minConfidence }
|
||||
})
|
||||
const r = rows.value.find(x => x.tag_id === tagId)
|
||||
if (r) r.min_confidence = minConfidence
|
||||
if (r) {
|
||||
r.min_confidence = minConfidence
|
||||
// The committed threshold changed the covered pool — refresh the row's
|
||||
// coverage so the table stays truthful after a save.
|
||||
try { r.coverage_count = (await coverage(tagId, minConfidence)).count }
|
||||
catch { /* leave the stale count rather than blank it */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Live "at threshold T, a sweep would cover ~N images" projection for the
|
||||
// tuning dashboard. Returns { count, threshold }.
|
||||
async function coverage(tagId, threshold) {
|
||||
return api.get(`/api/tags/${tagId}/allowlist/coverage`, {
|
||||
params: { threshold }
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(tagId) {
|
||||
@@ -26,5 +40,5 @@ export const useAllowlistStore = defineStore('allowlist', () => {
|
||||
rows.value = rows.value.filter(x => x.tag_id !== tagId)
|
||||
}
|
||||
|
||||
return { rows, loading, load, updateThreshold, remove }
|
||||
return { rows, loading, load, updateThreshold, coverage, remove }
|
||||
})
|
||||
|
||||
@@ -100,7 +100,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
})
|
||||
tagId = created.id
|
||||
}
|
||||
await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
||||
const res = await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
||||
body: { tag_id: tagId }
|
||||
})
|
||||
// Only drop from THIS image's category list — if the user navigated,
|
||||
@@ -108,10 +108,23 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
if (currentImageId === imageId) {
|
||||
_dropEverywhere(suggestion)
|
||||
}
|
||||
toast({
|
||||
text: `Tagged: ${suggestion.display_name}`,
|
||||
type: 'success'
|
||||
})
|
||||
_acceptToast('Tagged', suggestion.display_name, res)
|
||||
}
|
||||
|
||||
// One non-blocking toast for accept/alias. When the accept newly allowlisted
|
||||
// the tag, surface the coverage PROJECTION (#7) so the operator sees the
|
||||
// auto-apply reach without a blocking pre-accept preview — the apply itself
|
||||
// runs async, hence "~N".
|
||||
function _acceptToast(verb, displayName, res) {
|
||||
if (res?.allowlisted) {
|
||||
const n = res.projected_count
|
||||
toast({
|
||||
text: `${verb}: ${displayName} — allowlisted, auto-applying to ~${n} image${n === 1 ? '' : 's'}`,
|
||||
type: 'success'
|
||||
})
|
||||
} else {
|
||||
toast({ text: `${verb}: ${displayName}`, type: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
async function aliasAccept(suggestion, canonicalTagId) {
|
||||
@@ -123,7 +136,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
// reappearing unaliased. raw_name is null only for centroid hits, which
|
||||
// can't be aliased (the UI hides the action for them).
|
||||
const aliasString = suggestion.raw_name ?? suggestion.display_name
|
||||
await api.post(`/api/images/${imageId}/suggestions/alias`, {
|
||||
const res = await api.post(`/api/images/${imageId}/suggestions/alias`, {
|
||||
body: {
|
||||
alias_string: aliasString,
|
||||
alias_category: suggestion.category,
|
||||
@@ -133,10 +146,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
if (currentImageId === imageId) {
|
||||
_dropEverywhere(suggestion)
|
||||
}
|
||||
toast({
|
||||
text: `Aliased & tagged: ${suggestion.display_name}`,
|
||||
type: 'success'
|
||||
})
|
||||
_acceptToast('Aliased & tagged', suggestion.display_name, res)
|
||||
}
|
||||
|
||||
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
|
||||
|
||||
Reference in New Issue
Block a user