feat(gallery,tags): clear active filters
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 38s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m5s
CI / intcore (push) Successful in 8m36s

Two gaps where a filter couldn't be removed:

- Gallery: a tag_id filter (from clicking a tag) had no indicator or clear
  control — only post_id did (PostInfoHeader). Add an "Tag: <name> ✕" chip
  that clears the filter by dropping tag_id from the URL. New lightweight
  GET /api/tags/<id> resolves the name; the store fetches it on filter set.
- Tags view: the kind chip-group used mandatory="false" — a STRING ("false"
  is truthy in JS), which made the group mandatory so the active kind chip
  couldn't be deselected. Fixed to :mandatory="false" so the filter clears.

Tests: GET /tags/<id> shape + 404; gallery store resolves filterTagName.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:26:04 -04:00
parent e678d1dfdf
commit 4f9464d215
6 changed files with 77 additions and 3 deletions
+14 -1
View File
@@ -22,6 +22,9 @@ export const useGalleryStore = defineStore('gallery', () => {
const loading = ref(false)
const error = ref(null)
const filter = ref({ tag_id: null, post_id: null })
// Display name for the active tag filter, so the gallery can label a
// "Tag: X ✕" chip instead of leaving the filter invisible/unclearable.
const filterTagName = ref(null)
const timelineBuckets = ref([])
const timelineLoading = ref(false)
@@ -94,10 +97,20 @@ export const useGalleryStore = defineStore('gallery', () => {
function setTagFilter(tagId) {
filter.value.tag_id = tagId
filter.value.post_id = null
filterTagName.value = null
if (tagId) _resolveTagName(tagId) // fire-and-forget; chip label only
loadInitial()
loadTimeline()
}
async function _resolveTagName(tagId) {
try {
filterTagName.value = (await api.get(`/api/tags/${tagId}`)).name
} catch {
// Leave null — the chip falls back to "#<id>".
}
}
function setPostFilter(postId) {
filter.value.post_id = postId
filter.value.tag_id = null
@@ -110,7 +123,7 @@ export const useGalleryStore = defineStore('gallery', () => {
return {
images, dateGroups, hasMore, isEmpty, loading, error,
filter, timelineBuckets, timelineLoading,
filter, filterTagName, timelineBuckets, timelineLoading,
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter
}
})
+18 -1
View File
@@ -12,6 +12,13 @@
<div class="fc-gallery-layout">
<div class="fc-gallery-layout__main">
<PostInfoHeader />
<div v-if="store.filter.tag_id != null" class="fc-gallery-activefilter">
<v-chip
color="accent" variant="tonal" closable
prepend-icon="mdi-tag"
@click:close="clearTagFilter"
>Tag: {{ store.filterTagName || `#${store.filter.tag_id}` }}</v-chip>
</div>
<EmptyState v-if="store.isEmpty" />
<GalleryGrid v-else @open="openImage" />
</div>
@@ -24,7 +31,7 @@
<script setup>
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useGalleryStore } from '../stores/gallery.js'
import { useModalStore } from '../stores/modal.js'
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
@@ -38,6 +45,7 @@ const store = useGalleryStore()
const modal = useModalStore()
const sel = useGallerySelectionStore()
const route = useRoute()
const router = useRouter()
onMounted(async () => {
const postId = parseInt(route.query.post_id, 10)
@@ -63,6 +71,14 @@ watch(() => route.query.post_id, (q) => {
function openImage(id) {
modal.open(id)
}
// Clear the active tag filter by dropping it from the URL; the route
// watcher above resets the store filter and reloads.
function clearTagFilter() {
const q = { ...route.query }
delete q.tag_id
router.push({ query: q })
}
</script>
<style scoped>
@@ -72,6 +88,7 @@ function openImage(id) {
align-items: flex-start;
}
.fc-gallery-layout__main { flex: 1; min-width: 0; }
.fc-gallery-activefilter { margin-bottom: 12px; }
.fc-gallery-layout__sidebar { flex-shrink: 0; }
@media (max-width: 900px) {
+1 -1
View File
@@ -8,7 +8,7 @@
placeholder="Search tags" clearable style="max-width: 320px;"
/>
<v-chip-group
v-model="kind" selected-class="text-accent" mandatory="false"
v-model="kind" selected-class="text-accent" :mandatory="false"
>
<v-chip v-for="k in KINDS" :key="k" :value="k" filter size="small">
{{ k }}
+13
View File
@@ -50,6 +50,19 @@ describe('gallery store: tag/post filter exclusivity', () => {
expect(scrollCall).not.toContain('tag_id=')
})
it('setTagFilter resolves the tag name for the filter chip', async () => {
const s = useGalleryStore()
stubFetch((url) => {
if (url.includes('/api/tags/7')) {
return { status: 200, body: { id: 7, name: 'Asuka', kind: 'character' } }
}
return { status: 200, body: EMPTY } // scroll + timeline
})
s.setTagFilter(7)
expect(s.filter.tag_id).toBe(7)
await vi.waitFor(() => expect(s.filterTagName).toBe('Asuka'))
})
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
const s = useGalleryStore()
const urls = []