408fcd488a
Operator-flagged (again) on the tag-merge picker: Enter on the dropdown re-opens it instead of accepting the selection. I'd already patched this twice (fandom picker + fandom set dialog) with copy-pasted capture-phase handlers, so DRY it. New composable useAcceptOnEnter(accept): tracks the menu state and, on a capture-phase Enter, lets Vuetify pick when the menu is open but calls accept() (and blocks the re-open) when it's closed. Applied to every confirm-style picker: - TagsView merge-into picker (the reported one) - AliasPickerDialog - PostSeriesMenu add-to-existing - FandomPicker + FandomSetDialog (refactored off their bespoke handlers) One behavior, one place to change it.
173 lines
5.8 KiB
Vue
173 lines
5.8 KiB
Vue
<template>
|
|
<v-card>
|
|
<v-card-title class="text-body-1">Fandom for “{{ tag.name }}”</v-card-title>
|
|
<v-card-text>
|
|
<template v-if="!collision">
|
|
<!-- Keyboard flow matches FandomPicker (operator-specified 2026-06-07):
|
|
focus lands here via the parent dialog's @after-enter → focusSearch
|
|
(autofocus is unreliable inside a v-dialog focus-trap); Tab moves to
|
|
the new-fandom field; a SECOND Enter (menu closed) accepts the choice
|
|
by Saving instead of re-opening the dropdown. -->
|
|
<v-autocomplete
|
|
ref="fandomRef"
|
|
v-model="selectedId"
|
|
v-model:menu="menuOpen"
|
|
:items="store.fandomCache"
|
|
:item-title="(f) => f.name" :item-value="(f) => f.id"
|
|
label="Fandom" clearable density="compact"
|
|
:hint="selectedId == null
|
|
? 'No fandom — the character will be unassigned.' : ''"
|
|
persistent-hint
|
|
@keydown.enter.capture="onSearchEnter"
|
|
@keydown.tab="onSearchTab"
|
|
/>
|
|
<v-divider class="my-3" />
|
|
<p class="text-caption mb-2">Or create a new fandom:</p>
|
|
<div class="d-flex" style="gap: 8px;">
|
|
<v-text-field
|
|
ref="newNameRef"
|
|
v-model="newName" placeholder="New fandom name"
|
|
density="compact" hide-details
|
|
@keydown.enter.prevent="onCreate"
|
|
/>
|
|
<v-btn
|
|
:disabled="!newName.trim() || busy" rounded="pill"
|
|
@click="onCreate"
|
|
>Create</v-btn>
|
|
</div>
|
|
<v-alert
|
|
v-if="error" type="error" variant="tonal" density="compact"
|
|
class="mt-3"
|
|
>{{ error }}</v-alert>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
|
A character named “{{ tag.name }}” already exists in that fandom.
|
|
</v-alert>
|
|
<p class="text-body-2">
|
|
Merge this tag into “{{ collision.target.name }}”?
|
|
{{ collision.source_image_count }} image
|
|
association{{ collision.source_image_count === 1 ? '' : 's' }}
|
|
will move over and this tag will be deleted{{
|
|
collision.will_alias ? ' (its name kept as a tagger alias)' : '' }}.
|
|
</p>
|
|
</template>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<template v-if="!collision">
|
|
<v-btn variant="text" :disabled="busy" @click="$emit('cancel')">
|
|
Cancel
|
|
</v-btn>
|
|
<v-btn
|
|
color="primary" rounded="pill" :loading="busy"
|
|
:disabled="selectedId === (tag.fandom_id ?? null)"
|
|
@click="onSave"
|
|
>Save</v-btn>
|
|
</template>
|
|
<template v-else>
|
|
<v-btn variant="text" :disabled="busy" @click="collision = null">
|
|
Back
|
|
</v-btn>
|
|
<v-btn
|
|
color="warning" variant="flat" rounded="pill" :loading="busy"
|
|
@click="onConfirmMerge"
|
|
>Merge</v-btn>
|
|
</template>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { nextTick, onMounted, ref } from 'vue'
|
|
import { useTagStore } from '../../stores/tags.js'
|
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
|
|
|
const props = defineProps({ tag: { type: Object, required: true } })
|
|
const emit = defineEmits(['updated', 'cancel'])
|
|
|
|
const store = useTagStore()
|
|
|
|
const selectedId = ref(props.tag.fandom_id ?? null)
|
|
const newName = ref('')
|
|
const busy = ref(false)
|
|
const error = ref(null)
|
|
const collision = ref(null)
|
|
const fandomRef = ref(null)
|
|
const newNameRef = ref(null)
|
|
|
|
// Enter on the closed dropdown Saves the changed fandom instead of re-opening it.
|
|
const { menuOpen, onEnter: onSearchEnter } = useAcceptOnEnter(() => {
|
|
if (busy.value) return
|
|
if (selectedId.value !== (props.tag.fandom_id ?? null)) onSave()
|
|
})
|
|
|
|
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
|
|
onMounted(() => store.loadFandoms())
|
|
|
|
// Exposed so the parent dialog can focus the search field on @after-enter —
|
|
// the reliable point to grab focus (the dialog transition + focus-trap are done).
|
|
function focusSearch() {
|
|
nextTick(() => {
|
|
fandomRef.value?.focus?.()
|
|
// Keep the menu closed after a programmatic focus so the very next Enter
|
|
// accepts the value (Saves) instead of being swallowed as a menu interaction.
|
|
menuOpen.value = false
|
|
})
|
|
}
|
|
defineExpose({ focusSearch })
|
|
|
|
async function onCreate() {
|
|
const name = newName.value.trim()
|
|
if (!name) return
|
|
busy.value = true
|
|
error.value = null
|
|
try {
|
|
const f = await store.createFandom(name)
|
|
selectedId.value = f.id
|
|
newName.value = ''
|
|
// The created fandom is now the selection; hand focus back to the dropdown
|
|
// so a single Enter saves it (matches FandomPicker's flow).
|
|
focusSearch()
|
|
} catch (e) {
|
|
error.value = e.message || String(e)
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
// Tab from the search field goes to the "new fandom" text field (operator-
|
|
// specified). Shift+Tab keeps normal reverse traversal.
|
|
function onSearchTab(e) {
|
|
if (e.shiftKey) return
|
|
e.preventDefault()
|
|
menuOpen.value = false
|
|
nextTick(() => newNameRef.value?.focus?.())
|
|
}
|
|
|
|
async function save(merge) {
|
|
busy.value = true
|
|
error.value = null
|
|
try {
|
|
const body = await store.setFandom(props.tag.id, selectedId.value, { merge })
|
|
emit('updated', body)
|
|
} catch (e) {
|
|
// 409 on first attempt → surface the merge confirmation; cross-fandom
|
|
// collisions can't go through the regular /merge endpoint, so the
|
|
// resolution is a second setFandom with merge: true.
|
|
if (!merge && e.status === 409 && e.body && e.body.target) {
|
|
collision.value = e.body
|
|
} else {
|
|
error.value = e.message || String(e)
|
|
collision.value = null
|
|
}
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
function onSave() { save(false) }
|
|
function onConfirmMerge() { save(true) }
|
|
</script>
|