diff --git a/frontend/src/components/discovery/TagCard.vue b/frontend/src/components/discovery/TagCard.vue
index acb3905..4475aab 100644
--- a/frontend/src/components/discovery/TagCard.vue
+++ b/frontend/src/components/discovery/TagCard.vue
@@ -44,7 +44,31 @@
@@ -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; }
diff --git a/frontend/src/views/TagsView.vue b/frontend/src/views/TagsView.vue
index 212d43a..552c7ea 100644
--- a/frontend/src/views/TagsView.vue
+++ b/frontend/src/views/TagsView.vue
@@ -27,6 +27,7 @@
@@ -41,6 +42,49 @@
@confirm="confirmMerge"
@cancel="pendingMerge = null"
/>
+
+
+
+ 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 }}).
+
+
+
+
+
+ Cancel
+ Merge
+
+
+
+
+
@@ -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.
+ }
+}