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:
@@ -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>
|
||||||
Reference in New Issue
Block a user