Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps) #90

Merged
bvandeusen merged 10 commits from dev into main 2026-06-10 00:24:01 -04:00
9 changed files with 211 additions and 196 deletions
Showing only changes of commit c774042a85 - Show all commits
@@ -0,0 +1,50 @@
<template>
<!-- Canonical kebab (overflow ) menu the single primitive every kebab in
the app uses (DRY pattern-consistency sweep 2026-06-09). It bakes in the
modal-safe activator strategy that the image-modal kebabs needed (#711):
a manual v-model toggled by @click.stop, `activator="parent"` for
positioning only, `:open-on-click="false"`, and a z-index above the modal
(2000) so a kebab works identically inside a teleported modal and on a
plain page. Menu items go in the default slot; close-on-content-click
(Vuetify default) closes the menu when one is chosen. -->
<span class="fc-kebab">
<v-btn
:icon="icon"
:size="size"
:variant="variant"
:aria-label="label"
@click.stop="open = !open"
/>
<v-menu
v-model="open"
activator="parent"
:open-on-click="false"
:location="location"
:z-index="2400"
>
<v-list density="compact" :min-width="minWidth">
<slot />
</v-list>
</v-menu>
</span>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
icon: { type: String, default: 'mdi-dots-vertical' },
size: { type: String, default: 'x-small' },
variant: { type: String, default: 'text' },
location: { type: String, default: 'bottom end' },
// aria-label for the trigger (every kebab should name what it acts on).
label: { type: String, default: 'More actions' },
minWidth: { type: [String, Number], default: undefined },
})
const open = ref(false)
</script>
<style scoped>
.fc-kebab { display: inline-flex; align-items: center; }
</style>
+20 -28
View File
@@ -46,34 +46,25 @@
<v-chip size="x-small" label>{{ card.kind }}</v-chip> <v-chip size="x-small" label>{{ card.kind }}</v-chip>
<div class="fc-tagcard__meta-right"> <div class="fc-tagcard__meta-right">
<span class="fc-tagcard__count">{{ card.image_count }}</span> <span class="fc-tagcard__count">{{ card.image_count }}</span>
<v-menu> <KebabMenu class="fc-tagcard__menu" :label="`Actions for ${card.name}`">
<template #activator="{ props: act }"> <v-list-item
<v-btn v-if="card.kind === 'character'"
class="fc-tagcard__menu" title="Set fandom…"
icon="mdi-dots-vertical" size="x-small" variant="text" prepend-icon="mdi-book-open-page-variant"
v-bind="act" @click.stop @click="$emit('set-fandom', card)"
/> />
</template> <v-list-item
<v-list density="compact"> title="Merge with…"
<v-list-item prepend-icon="mdi-call-merge"
v-if="card.kind === 'character'" @click="$emit('merge-with', card)"
title="Set fandom…" />
prepend-icon="mdi-book-open-page-variant" <v-list-item
@click="$emit('set-fandom', card)" title="Delete tag"
/> prepend-icon="mdi-delete"
<v-list-item base-color="error"
title="Merge with…" @click="$emit('delete', card)"
prepend-icon="mdi-call-merge" />
@click="$emit('merge-with', card)" </KebabMenu>
/>
<v-list-item
title="Delete tag"
prepend-icon="mdi-delete"
base-color="error"
@click="$emit('delete', card)"
/>
</v-list>
</v-menu>
</div> </div>
</div> </div>
</v-card-text> </v-card-text>
@@ -82,6 +73,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ card: { type: Object, required: true } }) const props = defineProps({ card: { type: Object, required: true } })
const emit = defineEmits([ const emit = defineEmits([
@@ -19,46 +19,29 @@
> >
Accept Accept
</v-btn> </v-btn>
<!-- Operator-flagged 2026-06-04: the kebab still wasn't opening. The <!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
prior `#activator` + `v-bind="props"` path never toggled the menu teleported image modal #711). -->
inside this teleported modal, while v-model-driven overlays (the <KebabMenu
dialogs in this modal) work fine. So drive the menu explicitly: class="fc-suggestion__menu" size="small" variant="outlined"
the button toggles `menuOpen` with @click.stop (also shields any :label="`More actions for ${suggestion.display_name}`"
parent), and `activator="parent"` anchors the menu for positioning >
only — `:open-on-click="false"` keeps Vuetify's activator-click out <v-list-item @click="$emit('alias', suggestion)">
of it, so there's a single, reliable opener. --> <v-list-item-title>Treat as alias for</v-list-item-title>
<span class="fc-suggestion__menu-wrap"> </v-list-item>
<v-btn <v-list-item @click="$emit('dismiss', suggestion)">
class="fc-suggestion__menu" <v-list-item-title>Dismiss for this image</v-list-item-title>
icon="mdi-dots-vertical" size="small" </v-list-item>
variant="outlined" density="compact" </KebabMenu>
:aria-label="`More actions for ${suggestion.display_name}`"
@click.stop="menuOpen = !menuOpen"
/>
<v-menu
v-model="menuOpen" activator="parent" :open-on-click="false"
:z-index="2400"
>
<v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)">
<v-list-item-title>Treat as alias for…</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('dismiss', suggestion)">
<v-list-item-title>Dismiss for this image</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed } from 'vue'
import KebabMenu from '../common/KebabMenu.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>
@@ -102,11 +85,6 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
.fc-suggestion__accept :deep(.v-btn__content) { .fc-suggestion__accept :deep(.v-btn__content) {
font-size: 12px; letter-spacing: 0.02em; font-size: 12px; letter-spacing: 0.02em;
} }
.fc-suggestion__menu-wrap {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.fc-suggestion__menu { .fc-suggestion__menu {
flex: 0 0 auto; flex: 0 0 auto;
} }
+12 -31
View File
@@ -1,9 +1,4 @@
<template> <template>
<!-- One tag chip + its kebab. The kebab uses the SAME explicit-menu pattern
as SuggestionItem (activator="parent" + :open-on-click="false" + a manual
v-model), because the #activator / v-bind="props" pattern never toggles a
v-menu inside the teleported ImageViewer modal (#711, re-fixed 2026-06-07
after the first attempt used the broken pattern). -->
<span class="fc-tag-chip"> <span class="fc-tag-chip">
<v-chip <v-chip
size="small" closable size="small" closable
@@ -16,41 +11,28 @@
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'" :title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span> ><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
</v-chip> </v-chip>
<span class="fc-tag-chip__menu-wrap"> <!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
<v-btn teleported image modal #711). -->
class="fc-tag-chip__kebab" <KebabMenu class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`">
icon="mdi-dots-vertical" size="x-small" <v-list-item @click="$emit('rename', tag)">
variant="text" density="comfortable" <v-list-item-title>Rename</v-list-item-title>
:aria-label="`More actions for ${tag.name}`" </v-list-item>
@click.stop="menuOpen = !menuOpen" <v-list-item v-if="tag.kind === 'character'" @click="$emit('set-fandom', tag)">
/> <v-list-item-title>Set fandom</v-list-item-title>
<!-- :z-index above the modal's 2000 — the teleported menu otherwise lands </v-list-item>
BEHIND .fc-viewer and gets blurred by its backdrop-filter (operator </KebabMenu>
saw it ghosted behind the sidebar, 2026-06-07). THIS is what made the
kebab look dead the whole time: it opened, just underneath. -->
<v-menu v-model="menuOpen" activator="parent" :open-on-click="false" :z-index="2400">
<v-list density="compact">
<v-list-item @click="$emit('rename', tag)">
<v-list-item-title>Rename…</v-list-item-title>
</v-list-item>
<v-list-item v-if="tag.kind === 'character'" @click="$emit('set-fandom', tag)">
<v-list-item-title>Set fandom…</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</span>
</span> </span>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useTagStore } from '../../stores/tags.js' import { useTagStore } from '../../stores/tags.js'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ tag: { type: Object, required: true } }) const props = defineProps({ tag: { type: Object, required: true } })
defineEmits(['remove', 'rename', 'set-fandom']) defineEmits(['remove', 'rename', 'set-fandom'])
const store = useTagStore() const store = useTagStore()
const menuOpen = ref(false)
// Show a character's fandom inline (truncated). Falls back to a bare arrow when // Show a character's fandom inline (truncated). Falls back to a bare arrow when
// only fandom_id is known but the name wasn't resolved (older payloads). // only fandom_id is known but the name wasn't resolved (older payloads).
@@ -71,7 +53,6 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
<style scoped> <style scoped>
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; } .fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; }
.fc-tag-chip__menu-wrap { display: inline-flex; align-items: center; }
.fc-tag-chip__kebab { opacity: 0.7; } .fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; } .fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; } .fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
@@ -30,25 +30,20 @@
<span v-else class="fc-muted"></span> <span v-else class="fc-muted"></span>
</td> </td>
<td class="text-right"> <td class="text-right">
<v-menu> <KebabMenu :label="`Actions for backup ${r.id}`">
<template #activator="{ props: act }"> <v-list-item
<v-btn icon="mdi-dots-vertical" size="x-small" variant="text" v-bind="act" /> :title="r.tag ? 'Untag' : 'Tag…'"
</template> @click="$emit('tag', r)"
<v-list density="compact"> />
<v-list-item <v-list-item
:title="r.tag ? 'Untag' : 'Tag…'" title="Restore…" :disabled="r.status !== 'ok'"
@click="$emit('tag', r)" @click="$emit('restore', r)"
/> />
<v-list-item <v-list-item
title="Restore…" :disabled="r.status !== 'ok'" title="Delete…"
@click="$emit('restore', r)" @click="$emit('delete', r)"
/> />
<v-list-item </KebabMenu>
title="Delete…"
@click="$emit('delete', r)"
/>
</v-list>
</v-menu>
</td> </td>
</tr> </tr>
<tr v-if="!runs.length"> <tr v-if="!runs.length">
@@ -60,6 +55,7 @@
<script setup> <script setup>
import { formatRelative as fmtRelative } from '../../utils/date.js' import { formatRelative as fmtRelative } from '../../utils/date.js'
import KebabMenu from '../common/KebabMenu.vue'
defineProps({ runs: { type: Array, default: () => [] } }) defineProps({ runs: { type: Array, default: () => [] } })
defineEmits(['restore', 'delete', 'tag']) defineEmits(['restore', 'delete', 'tag'])
@@ -26,40 +26,33 @@
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
<v-menu location="bottom end"> <KebabMenu size="small" :min-width="260" label="More actions">
<template #activator="{ props: menuProps }"> <v-list-item
<v-btn icon size="small" variant="text" v-bind="menuProps" @click.stop> v-if="isPatreon && !running"
<v-icon>mdi-dots-vertical</v-icon> prepend-icon="mdi-backup-restore"
<v-tooltip activator="parent" location="top">More actions</v-tooltip> @click="emit('recover', source)"
</v-btn> >
</template> <v-list-item-title>Recover dropped near-duplicates</v-list-item-title>
<v-list density="compact" min-width="260"> <v-list-item-subtitle>
<v-list-item Re-fetch images previously dropped as near-dups and re-judge them
v-if="isPatreon && !running" under the current similarity threshold
prepend-icon="mdi-backup-restore" </v-list-item-subtitle>
@click="emit('recover', source)" </v-list-item>
> <v-divider v-if="isPatreon && !running" />
<v-list-item-title>Recover dropped near-duplicates</v-list-item-title> <v-list-item
<v-list-item-subtitle> base-color="error"
Re-fetch images previously dropped as near-dups and re-judge them prepend-icon="mdi-delete-outline"
under the current similarity threshold @click="emit('remove', source)"
</v-list-item-subtitle> >
</v-list-item> <v-list-item-title>Remove source</v-list-item-title>
<v-divider v-if="isPatreon && !running" /> </v-list-item>
<v-list-item </KebabMenu>
base-color="error"
prepend-icon="mdi-delete-outline"
@click="emit('remove', source)"
>
<v-list-item-title>Remove source</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ const props = defineProps({
source: { type: Object, required: true }, source: { type: Object, required: true },
+24 -31
View File
@@ -67,37 +67,29 @@
@click="openPicker(ch.id)" @click="openPicker(ch.id)"
>Add pages</v-btn> >Add pages</v-btn>
<v-menu location="bottom end"> <KebabMenu size="small" label="Part actions">
<template #activator="{ props }"> <v-list-item
<v-btn prepend-icon="mdi-chevron-up" title="Move up"
v-bind="props" size="small" variant="text" :disabled="ci === 0"
icon="mdi-dots-vertical" title="Part actions" @click="store.moveChapter(ch.id, -1)"
/> />
</template> <v-list-item
<v-list density="compact"> prepend-icon="mdi-chevron-down" title="Move down"
<v-list-item :disabled="ci === store.chapters.length - 1"
prepend-icon="mdi-chevron-up" title="Move up" @click="store.moveChapter(ch.id, 1)"
:disabled="ci === 0" />
@click="store.moveChapter(ch.id, -1)" <v-list-item
/> prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
<v-list-item :disabled="ci === 0"
prepend-icon="mdi-chevron-down" title="Move down" @click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
:disabled="ci === store.chapters.length - 1" />
@click="store.moveChapter(ch.id, 1)" <v-divider />
/> <v-list-item
<v-list-item prepend-icon="mdi-delete-outline" title="Delete part"
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous" base-color="error"
:disabled="ci === 0" @click="confirmDelete(ch)"
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)" />
/> </KebabMenu>
<v-divider />
<v-list-item
prepend-icon="mdi-delete-outline" title="Delete part"
base-color="error"
@click="confirmDelete(ch)"
/>
</v-list>
</v-menu>
</header> </header>
<div class="fc-part__statedrow"> <div class="fc-part__statedrow">
@@ -230,6 +222,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js' import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js' import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import KebabMenu from '../components/common/KebabMenu.vue'
const route = useRoute() const route = useRoute()
const store = useSeriesManageStore() const store = useSeriesManageStore()
+15 -21
View File
@@ -60,27 +60,20 @@
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap"> <span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
<v-icon size="x-small">mdi-alert-outline</v-icon> gap <v-icon size="x-small">mdi-alert-outline</v-icon> gap
</span> </span>
<v-menu> <KebabMenu
<template #activator="{ props: menuProps }"> class="fc-sbcard__kebab" variant="flat"
<v-btn :label="`Actions for ${s.name}`"
v-bind="menuProps" >
icon="mdi-dots-vertical" size="x-small" variant="flat" <v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)">
class="fc-sbcard__kebab" :aria-label="`Actions for ${s.name}`" <v-list-item-title>Rename</v-list-item-title>
@click.stop </v-list-item>
/> <v-list-item
</template> prepend-icon="mdi-delete" base-color="error"
<v-list density="compact"> @click.stop="openDelete(s)"
<v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)"> >
<v-list-item-title>Rename</v-list-item-title> <v-list-item-title>Delete</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item </KebabMenu>
prepend-icon="mdi-delete" base-color="error"
@click.stop="openDelete(s)"
>
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div> </div>
<div class="fc-sbcard__body"> <div class="fc-sbcard__body">
<h3 class="fc-sbcard__name">{{ s.name }}</h3> <h3 class="fc-sbcard__name">{{ s.name }}</h3>
@@ -210,6 +203,7 @@ import { useRouter } from 'vue-router'
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js' import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js' import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
import TagRenameDialog from '../components/modal/TagRenameDialog.vue' import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
import KebabMenu from '../components/common/KebabMenu.vue'
const router = useRouter() const router = useRouter()
const store = useSeriesBrowseStore() const store = useSeriesBrowseStore()
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import KebabMenu from '../../src/components/common/KebabMenu.vue'
// Canonical kebab primitive (DRY pattern-consistency sweep). Vuetify components
// are left unresolved by the test harness, so we assert the wiring the consumers
// rely on: the trigger's aria-label, the default kebab glyph, and that the
// consumer's menu items (default slot) are rendered inside the menu.
function mountKebab (props = {}) {
return mount(KebabMenu, {
props,
slots: {
default: '<v-list-item>Rename</v-list-item><v-list-item>Delete</v-list-item>',
},
})
}
describe('KebabMenu', () => {
it('renders the trigger with the provided aria-label and the slotted items', () => {
const w = mountKebab({ label: 'Actions for Foo' })
expect(w.text()).toContain('Rename')
expect(w.text()).toContain('Delete')
expect(w.html()).toContain('Actions for Foo')
})
it('defaults the trigger glyph to the kebab icon', () => {
expect(mountKebab().html()).toContain('mdi-dots-vertical')
})
it('passes presentational props through to the trigger/menu', () => {
const w = mountKebab({ size: 'small', variant: 'flat', location: 'bottom end' })
const html = w.html()
expect(html).toContain('small')
expect(html).toContain('flat')
})
})