feat(tagging): allowlist tuning dashboard + post-accept toast + merge preview UI (#7c/#7d/#8b)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Failing after 3m17s

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:
2026-06-23 01:41:09 -04:00
parent 7127714316
commit e49cea3eba
5 changed files with 204 additions and 39 deletions
+3 -2
View File
@@ -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 } },
))
}
+16 -2
View File
@@ -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 }
})
+20 -10
View File
@@ -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