feat(frontend): inline tag rename, merge confirm dialog, TagsView wiring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:42:46 -04:00
parent 1d1574e586
commit a50701ee6d
3 changed files with 115 additions and 7 deletions
@@ -0,0 +1,40 @@
<template>
<v-dialog :model-value="modelValue" max-width="460" @update:model-value="$emit('cancel')">
<v-card>
<v-card-title>Merge tags</v-card-title>
<v-card-text v-if="collision">
<p>
Merge <strong>{{ collision.sourceName }}</strong> into
<strong>{{ collision.target.name }}</strong>? This moves
<strong>{{ collision.sourceImageCount }}</strong>
image(s) onto <strong>{{ collision.target.name }}</strong>.
</p>
<p class="mt-2">
<template v-if="collision.willAlias">
{{ collision.sourceName }} will be kept as an alias of
{{ collision.target.name }} the tagger may still predict it.
</template>
<template v-else>
{{ collision.sourceName }} will be permanently deleted.
</template>
</p>
<p class="mt-2 text-medium-emphasis">This can't be undone.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('cancel')">Cancel</v-btn>
<v-btn color="accent" variant="flat" @click="$emit('confirm')">
Merge
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
collision: { type: Object, default: null }
})
defineEmits(['confirm', 'cancel'])
</script>
+53 -6
View File
@@ -1,5 +1,5 @@
<template>
<v-card class="fc-tagcard" @click="$emit('open', card.id)">
<v-card class="fc-tagcard" @click="onCardClick">
<div class="fc-tagcard__previews">
<img
v-for="(t, i) in card.preview_thumbnails" :key="i"
@@ -11,9 +11,25 @@
</div>
<v-card-text class="fc-tagcard__body">
<div class="fc-tagcard__name">
{{ card.name }}
<span v-if="card.kind === 'character' && card.fandom_name"
class="fc-tagcard__fandom"> {{ card.fandom_name }}</span>
<template v-if="!editing">
{{ card.name }}
<span v-if="card.kind === 'character' && card.fandom_name"
class="fc-tagcard__fandom"> {{ card.fandom_name }}</span>
<v-icon
class="fc-tagcard__edit" size="14" icon="mdi-pencil"
@click.stop="startEdit"
/>
</template>
<v-text-field
v-else
v-model="draft"
density="compact" variant="outlined" hide-details
autofocus
@click.stop
@keydown.stop.enter="submit"
@keydown.stop.esc="cancel"
@blur="cancel"
/>
</div>
<div class="fc-tagcard__meta">
<v-chip size="x-small" label>{{ card.kind }}</v-chip>
@@ -24,8 +40,31 @@
</template>
<script setup>
defineProps({ card: { type: Object, required: true } })
defineEmits(['open'])
import { ref } from 'vue'
const props = defineProps({ card: { type: Object, required: true } })
const emit = defineEmits(['open', 'rename'])
const editing = ref(false)
const draft = ref('')
function onCardClick() {
if (!editing.value) emit('open', props.card.id)
}
function startEdit() {
draft.value = props.card.name
editing.value = true
}
function cancel() {
editing.value = false
}
function submit() {
const name = draft.value.trim()
editing.value = false
if (name && name !== props.card.name) {
emit('rename', { id: props.card.id, name })
}
}
</script>
<style scoped>
@@ -48,4 +87,12 @@ defineEmits(['open'])
margin-top: 4px;
}
.fc-tagcard__count { font-variant-numeric: tabular-nums; opacity: 0.8; }
.fc-tagcard__edit {
opacity: 0;
margin-left: 6px;
cursor: pointer;
transition: opacity .15s ease;
}
.fc-tagcard:hover .fc-tagcard__edit { opacity: .6; }
.fc-tagcard__edit:hover { opacity: 1; }
</style>
+22 -1
View File
@@ -25,7 +25,8 @@
<div class="fc-tags__grid">
<TagCard
v-for="c in store.cards" :key="c.id" :card="c" @open="openTag"
v-for="c in store.cards" :key="c.id" :card="c"
@open="openTag" @rename="onRename"
/>
</div>
@@ -33,6 +34,13 @@
<v-progress-circular indeterminate color="accent" size="28" />
</div>
<div v-else-if="store.hasMore" ref="sentinelEl" class="fc-tags__sentinel" />
<MergeConfirmDialog
:model-value="pendingMerge !== null"
:collision="pendingMerge"
@confirm="confirmMerge"
@cancel="pendingMerge = null"
/>
</v-container>
</template>
@@ -41,6 +49,7 @@ import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTagDirectoryStore } from '../stores/tagDirectory.js'
import TagCard from '../components/discovery/TagCard.vue'
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
const KINDS = ['artist', 'character', 'copyright', 'general', 'series', 'meta']
const store = useTagDirectoryStore()
@@ -49,6 +58,18 @@ const router = useRouter()
const search = ref('')
const kind = ref(null)
const sentinelEl = ref(null)
const pendingMerge = ref(null)
async function onRename({ id, name }) {
const res = await store.rename(id, name)
if (res.collision) pendingMerge.value = res.collision
}
async function confirmMerge() {
const c = pendingMerge.value
pendingMerge.value = null
if (c) await store.merge(c.sourceId, c.target.id)
}
let debounce = null
watch(search, (v) => {