feat(fc2b): AliasPickerDialog + TagRenameDialog

AliasPicker: debounced autocomplete scoped to the prediction's category
(artist/character map to tag kinds; copyright searches unscoped),
returns the chosen canonical tag id. RenameDialog: PATCH /api/tags/<id>,
surfaces 409 collisions with the FC-2c merge hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 07:55:40 -04:00
parent a8cc6a27dc
commit 5ae2154e88
2 changed files with 121 additions and 0 deletions
@@ -0,0 +1,63 @@
<template>
<v-card>
<v-card-title>Treat as alias for</v-card-title>
<v-card-text>
<p class="text-caption mb-2">
Pick the existing tag the model's prediction should map to. All
future predictions of this name will resolve to your tag.
</p>
<v-autocomplete
v-model="selectedId"
:items="results"
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
:item-value="(t) => t.id"
:loading="loading"
label="Canonical tag"
no-filter clearable density="compact"
@update:search="onSearch"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn
color="primary" rounded="pill" :disabled="!selectedId"
@click="$emit('confirm', selectedId)"
>Use this tag</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
const props = defineProps({ category: { type: String, required: true } })
defineEmits(['confirm', 'cancel'])
const api = useApi()
const results = ref([])
const loading = ref(false)
const selectedId = ref(null)
let debounce = null
function onSearch(q) {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(async () => {
const query = (q || '').trim()
if (!query) { results.value = []; return }
loading.value = true
try {
// Scope the autocomplete to the prediction's category where it maps
// to a tag kind. 'copyright' has no tag kind; search unscoped there.
const kind = ['artist', 'character'].includes(props.category)
? props.category : null
const params = { q: query, limit: 20 }
if (kind) params.kind = kind
results.value = await api.get('/api/tags/autocomplete', { params })
} finally {
loading.value = false
}
}, 200)
}
</script>
@@ -0,0 +1,58 @@
<template>
<v-card>
<v-card-title>Rename tag</v-card-title>
<v-card-text>
<v-text-field
v-model="newName" label="New name" density="compact"
autofocus @keydown.enter="submit"
/>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-2">
{{ errorMsg }}
<div v-if="isCollision" class="text-caption mt-1">
Merging two tags into one lands in FC-2c.
</div>
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn
color="primary" rounded="pill"
:disabled="!newName.trim() || newName === tag.name"
:loading="busy" @click="submit"
>Rename</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
const props = defineProps({ tag: { type: Object, required: true } })
const emit = defineEmits(['renamed', 'cancel'])
const api = useApi()
const newName = ref(props.tag.name)
const errorMsg = ref(null)
const isCollision = ref(false)
const busy = ref(false)
async function submit() {
if (!newName.value.trim() || newName.value === props.tag.name) return
busy.value = true
errorMsg.value = null
isCollision.value = false
try {
const updated = await api.patch(`/api/tags/${props.tag.id}`, {
body: { name: newName.value.trim() }
})
emit('renamed', updated)
} catch (e) {
errorMsg.value = e.message
isCollision.value = e.status === 409
} finally {
busy.value = false
}
}
</script>