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:
@@ -2,33 +2,60 @@
|
|||||||
<MaintenanceTile
|
<MaintenanceTile
|
||||||
icon="mdi-playlist-check"
|
icon="mdi-playlist-check"
|
||||||
:title="`Allowlisted tags (${store.rows.length})`"
|
:title="`Allowlisted tags (${store.rows.length})`"
|
||||||
blurb="Tags auto-applied to images that score above their threshold."
|
blurb="Tags auto-applied to images that score above their threshold. Tune the
|
||||||
|
threshold and see how many images it would cover."
|
||||||
>
|
>
|
||||||
<v-data-table-virtual
|
<v-data-table-virtual
|
||||||
:headers="headers" :items="store.rows" :loading="store.loading"
|
:headers="headers" :items="store.rows" :loading="store.loading"
|
||||||
height="360" density="compact"
|
height="360" density="compact"
|
||||||
no-data-text="No tags on the allowlist yet — accept a suggestion to add one."
|
no-data-text="No tags on the allowlist yet — accept a suggestion to add one."
|
||||||
>
|
>
|
||||||
<template #item.min_confidence="{ item }">
|
<template #item.applied_count="{ item }">
|
||||||
<v-text-field
|
<span class="fc-num">{{ item.applied_count ?? '—' }}</span>
|
||||||
:model-value="item.min_confidence" type="number"
|
|
||||||
density="compact" hide-details style="max-width: 100px;"
|
|
||||||
:min="floor" max="1" step="0.05"
|
|
||||||
@update:model-value="(v) => onThreshold(item.tag_id, v)"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #item.min_confidence="{ item }">
|
||||||
|
<div class="fc-thr">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="item.min_confidence" type="number"
|
||||||
|
density="compact" hide-details style="max-width: 100px;"
|
||||||
|
:min="floor" max="1" step="0.05"
|
||||||
|
:aria-label="`Auto-apply threshold for ${item.tag_name}`"
|
||||||
|
@update:model-value="(v) => onThreshold(item, v)"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="proj[item.tag_id]"
|
||||||
|
class="fc-thr__proj"
|
||||||
|
:class="{ 'fc-thr__proj--loading': proj[item.tag_id].loading }"
|
||||||
|
:title="`At ${proj[item.tag_id].threshold}, a sweep would cover this many images`"
|
||||||
|
>≈ {{ proj[item.tag_id].count }} at {{ proj[item.tag_id].threshold }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.coverage_count="{ item }">
|
||||||
|
<span class="fc-num" :title="`Images a sweep covers at ${item.min_confidence}`">
|
||||||
|
{{ item.coverage_count ?? '—' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #item.actions="{ item }">
|
<template #item.actions="{ item }">
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-delete" size="x-small" variant="text" color="error"
|
icon="mdi-delete" size="x-small" variant="text" color="error"
|
||||||
|
:aria-label="`Remove ${item.tag_name} from the allowlist`"
|
||||||
@click="store.remove(item.tag_id)"
|
@click="store.remove(item.tag_id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-data-table-virtual>
|
</v-data-table-virtual>
|
||||||
|
<p class="fc-muted text-caption mt-2">
|
||||||
|
<strong>Applied</strong> = images currently carrying the tag.
|
||||||
|
<strong>Covers</strong> = images a sweep would auto-apply it to at the
|
||||||
|
current threshold. Lower the threshold to cover more (less certain) images.
|
||||||
|
</p>
|
||||||
</MaintenanceTile>
|
</MaintenanceTile>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, reactive } from 'vue'
|
||||||
import { useAllowlistStore } from '../../stores/allowlist.js'
|
import { useAllowlistStore } from '../../stores/allowlist.js'
|
||||||
import { useMLStore } from '../../stores/ml.js'
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
@@ -41,21 +68,53 @@ const ml = useMLStore()
|
|||||||
const floor = computed(() => ml.settings?.tagger_store_floor ?? 0.70)
|
const floor = computed(() => ml.settings?.tagger_store_floor ?? 0.70)
|
||||||
const headers = [
|
const headers = [
|
||||||
{ title: 'Tag', key: 'tag_name', sortable: true },
|
{ title: 'Tag', key: 'tag_name', sortable: true },
|
||||||
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 110 },
|
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 100 },
|
||||||
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 140 },
|
{ title: 'Applied', key: 'applied_count', sortable: true, width: 90 },
|
||||||
{ title: '', key: 'actions', sortable: false, width: 60 }
|
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 220 },
|
||||||
|
{ title: 'Covers', key: 'coverage_count', sortable: true, width: 90 },
|
||||||
|
{ title: '', key: 'actions', sortable: false, width: 56 }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Per-row live projection while the operator drags a threshold:
|
||||||
|
// proj[tagId] = { threshold, count, loading }
|
||||||
|
const proj = reactive({})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.load()
|
store.load()
|
||||||
if (!ml.settings) ml.loadSettings()
|
if (!ml.settings) ml.loadSettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
let debounce = null
|
const debounces = {}
|
||||||
function onThreshold(tagId, value) {
|
function onThreshold(item, value) {
|
||||||
if (debounce) clearTimeout(debounce)
|
const tagId = item.tag_id
|
||||||
debounce = setTimeout(() => {
|
const v = Math.max(parseFloat(value), floor.value)
|
||||||
const v = Math.max(parseFloat(value), floor.value)
|
if (!(v > 0 && v <= 1)) return
|
||||||
if (v > 0 && v <= 1) store.updateThreshold(tagId, v)
|
const shown = Number(v.toFixed(2))
|
||||||
|
// Optimistic live projection box (loading until the count returns).
|
||||||
|
proj[tagId] = { threshold: shown, count: proj[tagId]?.count ?? '…', loading: true }
|
||||||
|
if (debounces[tagId]) clearTimeout(debounces[tagId])
|
||||||
|
debounces[tagId] = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { count } = await store.coverage(tagId, v)
|
||||||
|
proj[tagId] = { threshold: shown, count, loading: false }
|
||||||
|
} catch {
|
||||||
|
delete proj[tagId] // drop the projection rather than show a wrong number
|
||||||
|
}
|
||||||
|
// Commit the new threshold (also refreshes the row's stored coverage_count).
|
||||||
|
store.updateThreshold(tagId, v)
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-num { font-variant-numeric: tabular-nums; }
|
||||||
|
.fc-thr { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.fc-thr__proj {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fc-thr__proj--loading { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
return _guard(() => api.delete(`/api/admin/tags/${tagId}`))
|
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(
|
return _guard(() => api.post(
|
||||||
`/api/admin/tags/${destId}/merge`,
|
`/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 }
|
body: { min_confidence: minConfidence }
|
||||||
})
|
})
|
||||||
const r = rows.value.find(x => x.tag_id === tagId)
|
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) {
|
async function remove(tagId) {
|
||||||
@@ -26,5 +40,5 @@ export const useAllowlistStore = defineStore('allowlist', () => {
|
|||||||
rows.value = rows.value.filter(x => x.tag_id !== tagId)
|
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
|
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 }
|
body: { tag_id: tagId }
|
||||||
})
|
})
|
||||||
// Only drop from THIS image's category list — if the user navigated,
|
// Only drop from THIS image's category list — if the user navigated,
|
||||||
@@ -108,10 +108,23 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_dropEverywhere(suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
toast({
|
_acceptToast('Tagged', suggestion.display_name, res)
|
||||||
text: `Tagged: ${suggestion.display_name}`,
|
}
|
||||||
type: 'success'
|
|
||||||
})
|
// 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) {
|
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
|
// reappearing unaliased. raw_name is null only for centroid hits, which
|
||||||
// can't be aliased (the UI hides the action for them).
|
// can't be aliased (the UI hides the action for them).
|
||||||
const aliasString = suggestion.raw_name ?? suggestion.display_name
|
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: {
|
body: {
|
||||||
alias_string: aliasString,
|
alias_string: aliasString,
|
||||||
alias_category: suggestion.category,
|
alias_category: suggestion.category,
|
||||||
@@ -133,10 +146,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_dropEverywhere(suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
toast({
|
_acceptToast('Aliased & tagged', suggestion.display_name, res)
|
||||||
text: `Aliased & tagged: ${suggestion.display_name}`,
|
|
||||||
type: 'success'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
|
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
|
||||||
|
|||||||
@@ -45,10 +45,9 @@
|
|||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>Merge “{{ mergeSource?.name }}” into…</v-card-title>
|
<v-card-title>Merge “{{ mergeSource?.name }}” into…</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<p class="text-body-2 mb-3" style="opacity: 0.7;">
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
Pick the target tag. Source tag will be deleted; all its
|
Pick the target tag. Source tag's image associations move to the
|
||||||
image associations will move to the target. Must be same
|
target. Must be same kind ({{ mergeSource?.kind }}).
|
||||||
kind ({{ mergeSource?.kind }}).
|
|
||||||
</p>
|
</p>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="mergeTargetId"
|
v-model="mergeTargetId"
|
||||||
@@ -60,15 +59,59 @@
|
|||||||
@update:search="onMergeSearch"
|
@update:search="onMergeSearch"
|
||||||
@keydown.enter.capture="onMergeEnter"
|
@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-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" @click="mergePickerOpen = false">Cancel</v-btn>
|
<v-btn variant="text" @click="mergePickerOpen = false">Cancel</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="warning" variant="flat" rounded="pill"
|
color="warning" variant="flat" rounded="pill"
|
||||||
:disabled="!mergeTargetId"
|
:disabled="!mergePreview || !mergePreview.compatible"
|
||||||
|
:loading="merging"
|
||||||
@click="onMergeConfirm"
|
@click="onMergeConfirm"
|
||||||
>Merge</v-btn>
|
>Merge{{ mergePreview ? ` ${mergePreview.images_moving} image(s)` : '' }}</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -114,6 +157,7 @@ import { useAdminStore } from '../stores/admin.js'
|
|||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||||
import { useAcceptOnEnter } from '../composables/useAcceptOnEnter.js'
|
import { useAcceptOnEnter } from '../composables/useAcceptOnEnter.js'
|
||||||
|
import { usePreviewCommit } from '../composables/usePreviewCommit.js'
|
||||||
import TagCard from '../components/discovery/TagCard.vue'
|
import TagCard from '../components/discovery/TagCard.vue'
|
||||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||||
@@ -207,10 +251,32 @@ const mergeHits = ref([])
|
|||||||
const mergeLoading = ref(false)
|
const mergeLoading = ref(false)
|
||||||
let mergeDebounce = null
|
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) {
|
function onMergeWith(card) {
|
||||||
mergeSource.value = card
|
mergeSource.value = card
|
||||||
mergeTargetId.value = null
|
mergeTargetId.value = null
|
||||||
mergeHits.value = []
|
mergeHits.value = []
|
||||||
|
mergePreview.value = null
|
||||||
mergePickerOpen.value = true
|
mergePickerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +300,9 @@ function onMergeSearch(q) {
|
|||||||
|
|
||||||
async function onMergeConfirm() {
|
async function onMergeConfirm() {
|
||||||
if (!mergeSource.value || !mergeTargetId.value) return
|
if (!mergeSource.value || !mergeTargetId.value) return
|
||||||
|
if (!mergePreview.value || !mergePreview.value.compatible) return
|
||||||
try {
|
try {
|
||||||
await adminStore.mergeTags(mergeTargetId.value, mergeSource.value.id)
|
await runMergeCommit()
|
||||||
mergePickerOpen.value = false
|
mergePickerOpen.value = false
|
||||||
store.reset()
|
store.reset()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -282,4 +349,18 @@ async function onDeleteTagConfirm() {
|
|||||||
.fc-tags__sentinel {
|
.fc-tags__sentinel {
|
||||||
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user