From 3f500e592ee192593db8206477dfab6cc93d3317 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 00:51:51 -0400 Subject: [PATCH] fc3k(ui): per-tag dots-menu with Merge + Delete actions; Tier-B count-surfacing modal Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/discovery/TagCard.vue | 37 +++++- frontend/src/views/TagsView.vue | 121 ++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) 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 @@
{{ card.kind }} - {{ card.image_count }} +
+ {{ card.image_count }} + + + + + + + +
@@ -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. + } +}