fix(modal/tags): fandom list, modal kebab, ESC-after-accept — #712 #711 #700
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s

#712 — fandom picker showed no existing fandoms: loadFandoms enumerated via
/tags/autocomplete with q=' ', which the backend strips to empty → returns [].
Switch to the cursor-paged /tags/directory?kind=fandom (loop all pages); drop the
load-once guard so the dialog reflects fandoms created elsewhere.

#711 — modal tag-chip kebab never opened: the kebab + menu were nested INSIDE the
v-chip, which swallowed the click / mis-anchored the teleported menu. Un-nest it
as a sibling v-btn using the standard v-menu activator slot (Vuetify wires the
click and stacks the overlay above the modal natively). Removes the openTagId
workaround.

#700 — ESC didn't close the modal after accepting a suggested tag: the guard
suppressed close while ANY .v-overlay--active existed, which includes tooltips —
a lingering tooltip blocked the close. Exclude .v-tooltip from the guard so only
real interactive overlays (menus/dialogs) keep ESC from closing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:15:02 -04:00
parent cb9b286c53
commit 4fe53cdf6b
5 changed files with 61 additions and 49 deletions
@@ -38,7 +38,8 @@ const store = useTagStore()
const selectedId = ref(null)
const newName = ref('')
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
onMounted(() => store.loadFandoms())
async function onCreate() {
const f = await store.createFandom(newName.value.trim())
@@ -84,7 +84,8 @@ const busy = ref(false)
const error = ref(null)
const collision = ref(null)
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
// Always refresh on open so the list reflects fandoms created elsewhere (#712).
onMounted(() => store.loadFandoms())
async function onCreate() {
const name = newName.value.trim()
@@ -106,7 +106,11 @@ function onKeyDown(ev) {
// overlay's own Esc handling fire instead of closing the whole
// modal mid-interaction. Vuetify marks open overlays with
// `.v-overlay--active`.
if (document.querySelector('.v-overlay--active')) return
// EXCLUDE tooltips (`.v-tooltip`): they're also `.v-overlay--active` while
// shown, so one lingering after a hover/click (e.g. just after accepting a
// suggested tag) wrongly suppressed the close (#700). Only real interactive
// overlays (menus/dialogs) should keep ESC from closing the modal.
if (document.querySelector('.v-overlay--active:not(.v-tooltip)')) return
ev.preventDefault()
emit('close')
} else if (ev.key === 'ArrowLeft') {
+38 -42
View File
@@ -2,45 +2,43 @@
<aside class="fc-tag-panel" aria-label="Tags for this image">
<h3 class="fc-tag-panel__title">Tags</h3>
<div class="fc-tag-panel__chips">
<v-chip
<!-- #711: the kebab + its menu used to be NESTED inside the v-chip, which
swallowed/mis-routed the click and mis-anchored the (teleported) menu
so it never opened. Render the kebab as a SIBLING of the chip and use
the standard v-menu activator slot Vuetify wires the click and
stacks the overlay above the modal natively. -->
<span
v-for="tag in modal.current?.tags || []"
:key="tag.id" size="small" closable
:color="store.colorFor(tag.kind)" variant="tonal"
@click:close="onRemove(tag.id)"
:key="tag.id" class="fc-tag-panel__chip"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span>
<!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
never opened inside this teleported modal. Drive it explicitly
instead (same mechanism as the dialogs below, which work): the
icon toggles `openTagId` with @click.stop (shielding the chip's
close button), and `activator="parent"` + `:open-on-click=false`
anchors the menu for positioning only. One tag's menu open at a
time, so a single id is enough. -->
<span class="kebab-wrap">
<v-icon
size="x-small" class="ml-1 kebab-icon"
icon="mdi-dots-vertical"
@click.stop="openTagId = openTagId === tag.id ? null : tag.id"
/>
<v-menu
:model-value="openTagId === tag.id"
activator="parent" :open-on-click="false"
@update:model-value="v => { if (!v) openTagId = null }"
>
<v-list density="compact">
<v-list-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title>
</v-list-item>
<v-list-item
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
>
<v-list-item-title>Set fandom…</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</v-chip>
<v-chip
size="small" closable
:color="store.colorFor(tag.kind)" variant="tonal"
@click:close="onRemove(tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span>
</v-chip>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props" icon="mdi-dots-vertical" size="x-small"
variant="text" density="comfortable"
class="fc-tag-panel__kebab" @click.stop
/>
</template>
<v-list density="compact">
<v-list-item @click="openRename(tag)">
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
<v-list-item
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
>
<v-list-item-title>Set fandom</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
</div>
@@ -88,9 +86,6 @@ import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore()
const store = useTagStore()
const errorMsg = ref(null)
// Which tag chip's kebab menu is open (only one at a time). Drives each
// chip menu's v-model so opening never depends on Vuetify's activator click.
const openTagId = ref(null)
const KIND_ICONS = {
general: 'mdi-tag', character: 'mdi-account-circle',
@@ -153,6 +148,7 @@ async function onFandomUpdated() {
margin-bottom: 12px;
}
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.kebab-wrap { display: inline-flex; align-items: center; }
.kebab-icon { cursor: pointer; }
.fc-tag-panel__chip { display: inline-flex; align-items: center; gap: 1px; }
.fc-tag-panel__kebab { opacity: 0.7; }
.fc-tag-panel__chip:hover .fc-tag-panel__kebab { opacity: 1; }
</style>
+14 -4
View File
@@ -34,10 +34,20 @@ export const useTagStore = defineStore('tags', () => {
}
async function loadFandoms() {
fandomCache.value = await api.get('/api/tags/autocomplete', {
params: { q: ' ', kind: 'fandom', limit: 200 }
})
return fandomCache.value
// List ALL fandoms via the cursor-paged tag directory. (#712: the old impl
// abused /tags/autocomplete with q=' ', which the backend strips to empty
// and returns [] — so the picker showed no existing fandoms.)
const all = []
let cursor = null
do {
const params = { kind: 'fandom', limit: 200 }
if (cursor) params.cursor = cursor
const body = await api.get('/api/tags/directory', { params })
all.push(...(body.cards || []))
cursor = body.next_cursor
} while (cursor)
fandomCache.value = all
return all
}
async function createFandom(name) {