fc3k(ui): per-tag dots-menu with Merge + Delete actions; Tier-B count-surfacing modal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,31 @@
|
||||
</div>
|
||||
<div class="fc-tagcard__meta">
|
||||
<v-chip size="x-small" label>{{ card.kind }}</v-chip>
|
||||
<span class="fc-tagcard__count">{{ card.image_count }}</span>
|
||||
<div class="fc-tagcard__meta-right">
|
||||
<span class="fc-tagcard__count">{{ card.image_count }}</span>
|
||||
<v-menu>
|
||||
<template #activator="{ props: act }">
|
||||
<v-btn
|
||||
class="fc-tagcard__menu"
|
||||
icon="mdi-dots-vertical" size="x-small" variant="text"
|
||||
v-bind="act" @click.stop
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
title="Merge with…"
|
||||
prepend-icon="mdi-call-merge"
|
||||
@click="$emit('merge-with', card)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Delete tag"
|
||||
prepend-icon="mdi-delete"
|
||||
base-color="error"
|
||||
@click="$emit('delete', card)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -54,7 +78,7 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({ card: { type: Object, required: true } })
|
||||
const emit = defineEmits(['open', 'rename', 'manage', 'read'])
|
||||
const emit = defineEmits(['open', 'rename', 'manage', 'read', 'merge-with', 'delete'])
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref('')
|
||||
@@ -106,4 +130,13 @@ function submit() {
|
||||
}
|
||||
.fc-tagcard:hover .fc-tagcard__edit { opacity: .6; }
|
||||
.fc-tagcard__edit:hover { opacity: 1; }
|
||||
.fc-tagcard__meta-right {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.fc-tagcard__menu {
|
||||
opacity: 0;
|
||||
transition: opacity .15s ease;
|
||||
}
|
||||
.fc-tagcard:hover .fc-tagcard__menu { opacity: .6; }
|
||||
.fc-tagcard__menu:hover { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<TagCard
|
||||
v-for="c in store.cards" :key="c.id" :card="c"
|
||||
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
|
||||
@merge-with="onMergeWith" @delete="onDeleteTag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +42,49 @@
|
||||
@confirm="confirmMerge"
|
||||
@cancel="pendingMerge = null"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="mergePickerOpen" max-width="520">
|
||||
<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>
|
||||
<v-autocomplete
|
||||
v-model="mergeTargetId"
|
||||
:items="mergeHits" item-title="name" item-value="id"
|
||||
:loading="mergeLoading"
|
||||
density="compact" variant="outlined" hide-details
|
||||
placeholder="Search target tag…" no-filter
|
||||
@update:search="onMergeSearch"
|
||||
/>
|
||||
</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"
|
||||
@click="onMergeConfirm"
|
||||
>Merge</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<DestructiveConfirmModal
|
||||
v-model="deleteTagModalOpen"
|
||||
action="delete"
|
||||
kind="tag"
|
||||
:run-id="deleteTagTarget?.id || 0"
|
||||
tier="B"
|
||||
:projected-counts="{ associations: deleteTagUsage }"
|
||||
:description="deleteTagTarget
|
||||
? `Delete tag “${deleteTagTarget.name}” and remove all image associations.`
|
||||
: ''"
|
||||
@confirm="onDeleteTagConfirm"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -48,8 +92,11 @@
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
|
||||
import { useAdminStore } from '../stores/admin.js'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import TagCard from '../components/discovery/TagCard.vue'
|
||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||
|
||||
// Must stay a subset of the backend TagKind enum (character, fandom,
|
||||
// general, series, archive, post, meta, rating). 'fandom' is this
|
||||
@@ -106,6 +153,80 @@ function onManage(id) {
|
||||
function onRead(id) {
|
||||
router.push({ name: 'series-read', params: { tagId: id } })
|
||||
}
|
||||
|
||||
// --- FC-3k tag merge + delete ----------------------------------------
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const api = useApi()
|
||||
|
||||
// Tag merge via dots-menu (separate from inline-rename collision flow above)
|
||||
const mergePickerOpen = ref(false)
|
||||
const mergeSource = ref(null)
|
||||
const mergeTargetId = ref(null)
|
||||
const mergeHits = ref([])
|
||||
const mergeLoading = ref(false)
|
||||
let mergeDebounce = null
|
||||
|
||||
function onMergeWith(card) {
|
||||
mergeSource.value = card
|
||||
mergeTargetId.value = null
|
||||
mergeHits.value = []
|
||||
mergePickerOpen.value = true
|
||||
}
|
||||
|
||||
function onMergeSearch(q) {
|
||||
if (mergeDebounce) clearTimeout(mergeDebounce)
|
||||
if (!q) { mergeHits.value = []; return }
|
||||
mergeDebounce = setTimeout(async () => {
|
||||
mergeLoading.value = true
|
||||
try {
|
||||
const hits = await api.get('/api/tags/autocomplete', {
|
||||
params: { q, kind: mergeSource.value?.kind, limit: 20 },
|
||||
})
|
||||
mergeHits.value = (hits || []).filter(
|
||||
(h) => h.id !== mergeSource.value?.id,
|
||||
)
|
||||
} finally {
|
||||
mergeLoading.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
async function onMergeConfirm() {
|
||||
if (!mergeSource.value || !mergeTargetId.value) return
|
||||
try {
|
||||
await adminStore.mergeTags(mergeTargetId.value, mergeSource.value.id)
|
||||
mergePickerOpen.value = false
|
||||
store.reset()
|
||||
} catch {
|
||||
// adminStore.lastError already set; UI surfaces it elsewhere.
|
||||
}
|
||||
}
|
||||
|
||||
// Tag delete via dots-menu (Tier B)
|
||||
const deleteTagModalOpen = ref(false)
|
||||
const deleteTagTarget = ref(null)
|
||||
const deleteTagUsage = ref(0)
|
||||
|
||||
async function onDeleteTag(card) {
|
||||
deleteTagTarget.value = card
|
||||
try {
|
||||
deleteTagUsage.value = await adminStore.tagUsageCount(card.id)
|
||||
} catch {
|
||||
deleteTagUsage.value = 0
|
||||
}
|
||||
deleteTagModalOpen.value = true
|
||||
}
|
||||
|
||||
async function onDeleteTagConfirm() {
|
||||
if (!deleteTagTarget.value) return
|
||||
try {
|
||||
await adminStore.deleteTag(deleteTagTarget.value.id)
|
||||
store.reset()
|
||||
} catch {
|
||||
// adminStore.lastError already set.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user