From e49cea3eba7033777e1075451b152d2b507db011 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 23 Jun 2026 01:41:09 -0400 Subject: [PATCH] feat(tagging): allowlist tuning dashboard + post-accept toast + merge preview UI (#7c/#7d/#8b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — ': — 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) Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX --- .../components/settings/AllowlistTable.vue | 95 +++++++++++++++---- frontend/src/stores/admin.js | 5 +- frontend/src/stores/allowlist.js | 18 +++- frontend/src/stores/suggestions.js | 30 ++++-- frontend/src/views/TagsView.vue | 95 +++++++++++++++++-- 5 files changed, 204 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/settings/AllowlistTable.vue b/frontend/src/components/settings/AllowlistTable.vue index b33b2a3..a198424 100644 --- a/frontend/src/components/settings/AllowlistTable.vue +++ b/frontend/src/components/settings/AllowlistTable.vue @@ -2,33 +2,60 @@ - + + diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index 86cd059..496398b 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -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 } }, )) } diff --git a/frontend/src/stores/allowlist.js b/frontend/src/stores/allowlist.js index 78db284..5d730f1 100644 --- a/frontend/src/stores/allowlist.js +++ b/frontend/src/stores/allowlist.js @@ -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 } }) diff --git a/frontend/src/stores/suggestions.js b/frontend/src/stores/suggestions.js index 344b1f5..36c926a 100644 --- a/frontend/src/stores/suggestions.js +++ b/frontend/src/stores/suggestions.js @@ -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 diff --git a/frontend/src/views/TagsView.vue b/frontend/src/views/TagsView.vue index 1bbae8b..387d9dc 100644 --- a/frontend/src/views/TagsView.vue +++ b/frontend/src/views/TagsView.vue @@ -45,10 +45,9 @@ Merge “{{ mergeSource?.name }}” into… -

- Pick the target tag. Source tag will be deleted; all its - image associations will move to the target. Must be same - kind ({{ mergeSource?.kind }}). +

+ Pick the target tag. Source tag's image associations move to the + target. Must be same kind ({{ mergeSource?.kind }}).

+ + {{ adminStore.lastError }} + + +
+
+ + Checking what would move… +
+
+

+ Moves {{ mergePreview.images_moving }} + image(s) onto {{ mergePreview.target_name }}. + + ({{ mergePreview.images_already_on_target }} already on it.) + +

+

+ {{ mergePreview.series_pages }} series page(s) repointed. +

+

+ + +

+ Different kind or fandom — this merge would be rejected. +
+ +
+
+
Cancel Merge + >Merge{{ mergePreview ? ` ${mergePreview.images_moving} image(s)` : '' }}
@@ -114,6 +157,7 @@ import { useAdminStore } from '../stores/admin.js' import { useApi } from '../composables/useApi.js' import { useInfiniteScroll } from '../composables/useInfiniteScroll.js' import { useAcceptOnEnter } from '../composables/useAcceptOnEnter.js' +import { usePreviewCommit } from '../composables/usePreviewCommit.js' import TagCard from '../components/discovery/TagCard.vue' import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue' import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue' @@ -207,10 +251,32 @@ const mergeHits = ref([]) const mergeLoading = ref(false) let mergeDebounce = null +// #8: dry-run preview (counts + sample of what moves) before the irreversible +// merge — same usePreviewCommit primitive the maintenance tiles use. +const { + previewData: mergePreview, previewing: mergePreviewing, committing: merging, + runPreview: runMergePreview, runCommit: runMergeCommit, +} = usePreviewCommit({ + preview: async () => + (await adminStore.mergeTags( + mergeTargetId.value, mergeSource.value.id, { dryRun: true }, + )).preview, + commit: () => adminStore.mergeTags( + mergeTargetId.value, mergeSource.value.id, { dryRun: false }, + ), +}) + +// Re-preview whenever the chosen target changes (and clear it when unset). +watch(mergeTargetId, (id) => { + if (id && mergeSource.value) runMergePreview() + else mergePreview.value = null +}) + function onMergeWith(card) { mergeSource.value = card mergeTargetId.value = null mergeHits.value = [] + mergePreview.value = null mergePickerOpen.value = true } @@ -234,8 +300,9 @@ function onMergeSearch(q) { async function onMergeConfirm() { if (!mergeSource.value || !mergeTargetId.value) return + if (!mergePreview.value || !mergePreview.value.compatible) return try { - await adminStore.mergeTags(mergeTargetId.value, mergeSource.value.id) + await runMergeCommit() mergePickerOpen.value = false store.reset() } catch { @@ -282,4 +349,18 @@ async function onDeleteTagConfirm() { .fc-tags__sentinel { display: flex; justify-content: center; padding: 32px 0; min-height: 60px; } +.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); } +.fc-merge-preview { + padding: 10px 12px; + border: 1px solid rgba(var(--v-theme-on-surface), 0.12); + border-radius: 10px; + background: rgba(var(--v-theme-on-surface), 0.03); +} +.fc-merge-thumbs { + display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; +} +.fc-merge-thumbs img { + width: 56px; height: 56px; object-fit: cover; border-radius: 6px; + background: rgb(var(--v-theme-surface-light)); +}