feat(gallery): tag→gallery nav from modal chips (#5) + OR/exclude tag scope (#6a)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m19s
CI / frontend-build (push) Successful in 19s

Cluster A, milestone #97. #5: clicking an image-modal tag chip's body now
closes the modal and opens the gallery filtered for that one tag (fresh
filter); ✕/kebab stay as the explicit remove/rename controls.

#6a (backend of OR/exclude filtering): gallery_service._apply_scope gains a
structured tag model — tag_or_groups (AND-of-OR: one EXISTS(tag_id IN group)
per group) + tag_exclude (NOT EXISTS(tag_id IN exclude)) — layered additively
on the existing tag_ids AND path so cursors/facets/deep-links are untouched.
Threaded through scroll/timeline/jump_cursor/facets/similar + facets common
dict; _require_single_filter rejects post_id combined with OR/exclude. API
parses tag_or (repeatable → one OR-group each) + tag_not (csv exclude).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
2026-06-23 01:11:42 -04:00
parent 7c94d99b9f
commit 23fab983a0
6 changed files with 258 additions and 20 deletions
+12 -1
View File
@@ -3,6 +3,10 @@
<v-chip
size="small" closable
:color="store.colorFor(tag.kind)" variant="tonal"
class="fc-tag-chip__nav"
role="link"
:title="`Browse images tagged “${tag.name}”`"
@click="$emit('navigate', tag)"
@click:close="$emit('remove', tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
@@ -30,7 +34,7 @@ import { useTagStore } from '../../stores/tags.js'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ tag: { type: Object, required: true } })
defineEmits(['remove', 'rename', 'set-fandom'])
defineEmits(['remove', 'rename', 'set-fandom', 'navigate'])
const store = useTagStore()
@@ -53,6 +57,13 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
<style scoped>
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; }
/* The chip body navigates to the filtered gallery (#5); signal it's clickable.
The close ✕ (remove) and the sibling kebab stay as the explicit controls. */
.fc-tag-chip__nav { cursor: pointer; }
.fc-tag-chip__nav:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 1px;
}
.fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
@@ -6,6 +6,7 @@
v-for="tag in modal.current?.tags || []"
:key="tag.id" :tag="tag"
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
@navigate="onNavigate"
/>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
</div>
@@ -51,6 +52,7 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import TagChip from './TagChip.vue'
@@ -61,9 +63,18 @@ import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore()
const suggestions = useSuggestionsStore()
const router = useRouter()
const errorMsg = ref(null)
const tagInputRef = ref(null)
// #5: clicking a tag chip's body leaves the modal and opens the gallery
// filtered for that single tag (a fresh filter — the obvious "show me more
// like this tag" move). Rename/set-fandom (kebab) and remove (✕) stay put.
async function onNavigate(tag) {
await modal.close()
router.push({ name: 'gallery', query: { tag_id: String(tag.id) } })
}
// Return focus to the tag input after a suggestion is accepted (from the
// Suggestions panel or the autocomplete dropdown) so the operator can keep
// typing the next tag without re-clicking the field (operator-asked 2026-06-08).