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:
@@ -45,10 +45,9 @@
|
||||
<v-card>
|
||||
<v-card-title>Merge “{{ mergeSource?.name }}” into…</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3" style="opacity: 0.7;">
|
||||
Pick the target tag. Source tag will be deleted; all its
|
||||
image associations will move to the target. Must be same
|
||||
kind ({{ mergeSource?.kind }}).
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Pick the target tag. Source tag's image associations move to the
|
||||
target. Must be same kind ({{ mergeSource?.kind }}).
|
||||
</p>
|
||||
<v-autocomplete
|
||||
v-model="mergeTargetId"
|
||||
@@ -60,15 +59,59 @@
|
||||
@update:search="onMergeSearch"
|
||||
@keydown.enter.capture="onMergeEnter"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="adminStore.lastError"
|
||||
type="warning" variant="tonal" density="compact" class="mt-3"
|
||||
>{{ adminStore.lastError }}</v-alert>
|
||||
|
||||
<!-- #8: dry-run preview of what the merge will move, before commit. -->
|
||||
<div v-if="mergeTargetId" class="fc-merge-preview mt-3">
|
||||
<div v-if="mergePreviewing" class="d-flex align-center ga-2">
|
||||
<v-progress-circular indeterminate size="18" width="2" color="accent" />
|
||||
<span class="text-body-2 fc-muted">Checking what would move…</span>
|
||||
</div>
|
||||
<div v-else-if="mergePreview">
|
||||
<p class="text-body-2 mb-1">
|
||||
Moves <strong>{{ mergePreview.images_moving }}</strong>
|
||||
image(s) onto <strong>{{ mergePreview.target_name }}</strong>.
|
||||
<span v-if="mergePreview.images_already_on_target" class="fc-muted">
|
||||
({{ mergePreview.images_already_on_target }} already on it.)
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="mergePreview.series_pages" class="text-body-2 mb-1">
|
||||
{{ mergePreview.series_pages }} series page(s) repointed.
|
||||
</p>
|
||||
<p class="text-body-2 mb-1">
|
||||
<template v-if="mergePreview.will_alias">
|
||||
“{{ mergePreview.source_name }}” kept as an alias — the tagger may still predict it.
|
||||
</template>
|
||||
<template v-else>
|
||||
“{{ mergePreview.source_name }}” will be permanently deleted.
|
||||
</template>
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="!mergePreview.compatible"
|
||||
type="error" variant="tonal" density="compact" class="mb-2"
|
||||
>Different kind or fandom — this merge would be rejected.</v-alert>
|
||||
<div v-if="mergePreview.sample_thumbnails?.length" class="fc-merge-thumbs">
|
||||
<img
|
||||
v-for="(t, i) in mergePreview.sample_thumbnails" :key="i"
|
||||
:src="t" alt="" loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="mergePickerOpen = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="warning" variant="flat" rounded="pill"
|
||||
:disabled="!mergeTargetId"
|
||||
:disabled="!mergePreview || !mergePreview.compatible"
|
||||
:loading="merging"
|
||||
@click="onMergeConfirm"
|
||||
>Merge</v-btn>
|
||||
>Merge{{ mergePreview ? ` ${mergePreview.images_moving} image(s)` : '' }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -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));
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user