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>
|
||||||
<div class="fc-tagcard__meta">
|
<div class="fc-tagcard__meta">
|
||||||
<v-chip size="x-small" label>{{ card.kind }}</v-chip>
|
<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>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -54,7 +78,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({ card: { type: Object, required: true } })
|
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 editing = ref(false)
|
||||||
const draft = ref('')
|
const draft = ref('')
|
||||||
@@ -106,4 +130,13 @@ function submit() {
|
|||||||
}
|
}
|
||||||
.fc-tagcard:hover .fc-tagcard__edit { opacity: .6; }
|
.fc-tagcard:hover .fc-tagcard__edit { opacity: .6; }
|
||||||
.fc-tagcard__edit:hover { opacity: 1; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<TagCard
|
<TagCard
|
||||||
v-for="c in store.cards" :key="c.id" :card="c"
|
v-for="c in store.cards" :key="c.id" :card="c"
|
||||||
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
|
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
|
||||||
|
@merge-with="onMergeWith" @delete="onDeleteTag"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,6 +42,49 @@
|
|||||||
@confirm="confirmMerge"
|
@confirm="confirmMerge"
|
||||||
@cancel="pendingMerge = null"
|
@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>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -48,8 +92,11 @@
|
|||||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
|
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 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'
|
||||||
|
|
||||||
// Must stay a subset of the backend TagKind enum (character, fandom,
|
// Must stay a subset of the backend TagKind enum (character, fandom,
|
||||||
// general, series, archive, post, meta, rating). 'fandom' is this
|
// general, series, archive, post, meta, rating). 'fandom' is this
|
||||||
@@ -106,6 +153,80 @@ function onManage(id) {
|
|||||||
function onRead(id) {
|
function onRead(id) {
|
||||||
router.push({ name: 'series-read', params: { tagId: 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user