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:
@@ -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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card class="fc-tagcard" @click="$emit('open', card.id)">
|
<v-card class="fc-tagcard" @click="onCardClick">
|
||||||
<div class="fc-tagcard__previews">
|
<div class="fc-tagcard__previews">
|
||||||
<img
|
<img
|
||||||
v-for="(t, i) in card.preview_thumbnails" :key="i"
|
v-for="(t, i) in card.preview_thumbnails" :key="i"
|
||||||
@@ -11,9 +11,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<v-card-text class="fc-tagcard__body">
|
<v-card-text class="fc-tagcard__body">
|
||||||
<div class="fc-tagcard__name">
|
<div class="fc-tagcard__name">
|
||||||
{{ card.name }}
|
<template v-if="!editing">
|
||||||
<span v-if="card.kind === 'character' && card.fandom_name"
|
{{ card.name }}
|
||||||
class="fc-tagcard__fandom">— {{ card.fandom_name }}</span>
|
<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>
|
||||||
<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>
|
||||||
@@ -24,8 +40,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({ card: { type: Object, required: true } })
|
import { ref } from 'vue'
|
||||||
defineEmits(['open'])
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -48,4 +87,12 @@ defineEmits(['open'])
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.fc-tagcard__count { font-variant-numeric: tabular-nums; opacity: 0.8; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
|
|
||||||
<div class="fc-tags__grid">
|
<div class="fc-tags__grid">
|
||||||
<TagCard
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -33,6 +34,13 @@
|
|||||||
<v-progress-circular indeterminate color="accent" size="28" />
|
<v-progress-circular indeterminate color="accent" size="28" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="store.hasMore" ref="sentinelEl" class="fc-tags__sentinel" />
|
<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>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -41,6 +49,7 @@ 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 TagCard from '../components/discovery/TagCard.vue'
|
import TagCard from '../components/discovery/TagCard.vue'
|
||||||
|
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||||
|
|
||||||
const KINDS = ['artist', 'character', 'copyright', 'general', 'series', 'meta']
|
const KINDS = ['artist', 'character', 'copyright', 'general', 'series', 'meta']
|
||||||
const store = useTagDirectoryStore()
|
const store = useTagDirectoryStore()
|
||||||
@@ -49,6 +58,18 @@ const router = useRouter()
|
|||||||
const search = ref('')
|
const search = ref('')
|
||||||
const kind = ref(null)
|
const kind = ref(null)
|
||||||
const sentinelEl = 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
|
let debounce = null
|
||||||
watch(search, (v) => {
|
watch(search, (v) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user