Compare commits

...
3 Commits
Author SHA1 Message Date
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusenandClaude Opus 4.8 86efbf7f2c fix(modal): kebab menus open via explicit v-model, not activator click
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / integration (push) Successful in 2m56s
Operator-confirmed on a fresh build: both the tag-chip and suggestion
kebabs still never opened. The prior 8326e54 'fix' only wrapped them in a
<span @click.stop> — inert for SuggestionItem (no parent capture) — and
never addressed why the `#activator`/`v-bind="props"` click failed to
toggle the menu inside the teleported ImageViewer modal. The dialogs in
that same modal open via v-model and work, so drive the menus the same way:

- The activator (v-btn / v-icon) toggles a reactive flag with @click.stop
  (which also shields the chip's close button / any parent).
- The v-menu binds that flag (v-model / :model-value) and uses
  activator="parent" with :open-on-click="false" purely for positioning,
  so opening no longer depends on Vuetify's activator-click path.
- TagPanel tracks a single openTagId (one chip menu open at a time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:01:55 -04:00
bvandeusenandClaude Opus 4.8 3a0cca5aca fix(tags): allow creating a character with no fandom
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m4s
Not all characters belong to a fandom (original characters, unsorted).
The create flow forced every new character through FandomPicker, whose
only outcomes were 'Use this fandom' (disabled until one is picked) or
Cancel (which aborts the whole creation) — there was no way to confirm a
character with no fandom.

- FandomPicker: add a 'No fandom' action that emits confirm(null).
- TagAutocomplete.onFandomChosen: pass fandom_id: null when null is
  emitted.

Backend already supported this end to end (Tag.fandom_id nullable, the
CHECK only forbids fandom_id on non-character kinds, tag_service
find_or_create defaults fandom_id=None, API reads body.get). A fandom can
still be assigned later from the chip kebab's 'Set fandom…'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:50:19 -04:00
4 changed files with 57 additions and 34 deletions
@@ -17,6 +17,10 @@
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<!-- Not every character belongs to a fandom (original characters,
unsorted, etc.). "No fandom" creates the character unassigned;
a fandom can still be set later from the chip's kebab menu. -->
<v-btn variant="text" @click="onNoFandom">No fandom</v-btn>
<v-spacer /> <v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn> <v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn> <v-btn :disabled="!selectedId" color="primary" rounded="pill" @click="onConfirm">Use this fandom</v-btn>
@@ -45,4 +49,9 @@ function onConfirm() {
const f = store.fandomCache.find(x => x.id === selectedId.value) const f = store.fandomCache.find(x => x.id === selectedId.value)
if (f) emit('confirm', f) if (f) emit('confirm', f)
} }
// Create the character with no fandom. Emits null so the caller knows this
// was a deliberate "unassigned", not a cancel.
function onNoFandom() {
emit('confirm', null)
}
</script> </script>
@@ -19,24 +19,25 @@
> >
Accept Accept
</v-btn> </v-btn>
<!-- Operator-flagged 2026-06-02: the kebab menu wasn't opening. <!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The
Wrapping in a <span @click.stop> matches the TagPanel chip prior `#activator` + `v-bind="props"` path never toggled the menu
fix — even though there's no parent click capture here today, inside this teleported modal, while v-model-driven overlays (the
the wrap is harmless and keeps both kebabs on the same dialogs in this modal) work fine. So drive the menu explicitly:
pattern. Click bubbles from the v-btn opens menu via the button toggles `menuOpen` with @click.stop (also shields any
activator props bubble continues to span stopPropagation parent), and `activator="parent"` anchors the menu for positioning
halts it. --> only — `:open-on-click="false"` keeps Vuetify's activator-click out
<span class="fc-suggestion__menu-wrap" @click.stop> of it, so there's a single, reliable opener. -->
<v-menu> <span class="fc-suggestion__menu-wrap">
<template #activator="{ props }">
<v-btn <v-btn
class="fc-suggestion__menu" class="fc-suggestion__menu"
icon="mdi-dots-vertical" size="small" icon="mdi-dots-vertical" size="small"
variant="outlined" density="compact" variant="outlined" density="compact"
:aria-label="`More actions for ${suggestion.display_name}`" :aria-label="`More actions for ${suggestion.display_name}`"
v-bind="props" @click.stop="menuOpen = !menuOpen"
/> />
</template> <v-menu
v-model="menuOpen" activator="parent" :open-on-click="false"
>
<v-list density="compact"> <v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)"> <v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for…</v-list-item-title> <v-list-item-title>Treat as alias for…</v-list-item-title>
@@ -51,11 +52,12 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
const props = defineProps({ suggestion: { type: Object, required: true } }) const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'dismiss']) defineEmits(['accept', 'alias', 'dismiss'])
const menuOpen = ref(false)
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
</script> </script>
@@ -147,10 +147,14 @@ function onCreate () {
reset() reset()
} }
// fandom is null when the user picked "No fandom" — characters don't all
// belong to a fandom. The backend already accepts fandom_id: null for the
// character kind (tag.kind check + nullable fandom_id), and a fandom can be
// assigned later from the chip kebab's "Set fandom…".
function onFandomChosen (fandom) { function onFandomChosen (fandom) {
fandomDialog.value = false fandomDialog.value = false
emit('pick-new', { emit('pick-new', {
name: pendingNewName, kind: 'character', fandom_id: fandom.id, name: pendingNewName, kind: 'character', fandom_id: fandom ? fandom.id : null,
}) })
pendingNewName = null pendingNewName = null
reset() reset()
+19 -11
View File
@@ -10,20 +10,24 @@
> >
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon> <v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span> {{ tag.name }}<span v-if="tag.fandom_id"></span>
<!-- Operator-flagged 2026-06-02: the previous activator had <!-- Operator-flagged 2026-06-04: the `#activator` + `v-bind` menu
`@click.stop` directly on the v-icon, which silently never opened inside this teleported modal. Drive it explicitly
overrode Vuetify's onClick from `v-bind="mp"` — the menu instead (same mechanism as the dialogs below, which work): the
never opened. Now the v-icon receives the activator icon toggles `openTagId` with @click.stop (shielding the chip's
onClick cleanly, and the wrapping span absorbs the close button), and `activator="parent"` + `:open-on-click=false`
bubbled click so the chip's close button isn't tripped. --> anchors the menu for positioning only. One tag's menu open at a
<span class="kebab-wrap" @click.stop> time, so a single id is enough. -->
<v-menu> <span class="kebab-wrap">
<template #activator="{ props: mp }">
<v-icon <v-icon
v-bind="mp" size="x-small" class="ml-1" size="x-small" class="ml-1 kebab-icon"
icon="mdi-dots-vertical" icon="mdi-dots-vertical"
@click.stop="openTagId = openTagId === tag.id ? null : tag.id"
/> />
</template> <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 density="compact">
<v-list-item @click="openRename(tag)"> <v-list-item @click="openRename(tag)">
<v-list-item-title>Rename…</v-list-item-title> <v-list-item-title>Rename…</v-list-item-title>
@@ -84,6 +88,9 @@ import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore() const modal = useModalStore()
const store = useTagStore() const store = useTagStore()
const errorMsg = ref(null) 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 = { const KIND_ICONS = {
general: 'mdi-tag', character: 'mdi-account-circle', general: 'mdi-tag', character: 'mdi-account-circle',
@@ -147,4 +154,5 @@ async function onFandomUpdated() {
} }
.fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; } .fc-tag-panel__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.kebab-wrap { display: inline-flex; align-items: center; } .kebab-wrap { display: inline-flex; align-items: center; }
.kebab-icon { cursor: pointer; }
</style> </style>