feat(gallery,tags): clear active filters
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:
@@ -194,6 +194,24 @@ async def remove_tag_from_image(image_id: int, tag_id: int):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/tags/<int:tag_id>", methods=["GET"])
|
||||||
|
async def get_tag(tag_id: int):
|
||||||
|
"""Resolve a single tag (used by the gallery to label its active
|
||||||
|
tag-filter chip)."""
|
||||||
|
async with get_session() as session:
|
||||||
|
tag = await session.get(Tag, tag_id)
|
||||||
|
if tag is None:
|
||||||
|
return jsonify({"error": "tag not found"}), 404
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"kind": tag.kind.value,
|
||||||
|
"fandom_id": tag.fandom_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
|
@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
|
||||||
async def update_tag(tag_id: int):
|
async def update_tag(tag_id: int):
|
||||||
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
|
"""Rename and/or re-fandom a tag. Body may carry `name` and/or
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const filter = ref({ tag_id: null, post_id: 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 timelineBuckets = ref([])
|
||||||
const timelineLoading = ref(false)
|
const timelineLoading = ref(false)
|
||||||
@@ -94,10 +97,20 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
function setTagFilter(tagId) {
|
function setTagFilter(tagId) {
|
||||||
filter.value.tag_id = tagId
|
filter.value.tag_id = tagId
|
||||||
filter.value.post_id = null
|
filter.value.post_id = null
|
||||||
|
filterTagName.value = null
|
||||||
|
if (tagId) _resolveTagName(tagId) // fire-and-forget; chip label only
|
||||||
loadInitial()
|
loadInitial()
|
||||||
loadTimeline()
|
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) {
|
function setPostFilter(postId) {
|
||||||
filter.value.post_id = postId
|
filter.value.post_id = postId
|
||||||
filter.value.tag_id = null
|
filter.value.tag_id = null
|
||||||
@@ -110,7 +123,7 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
images, dateGroups, hasMore, isEmpty, loading, error,
|
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||||
filter, timelineBuckets, timelineLoading,
|
filter, filterTagName, timelineBuckets, timelineLoading,
|
||||||
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter
|
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter, setPostFilter
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
<div class="fc-gallery-layout">
|
<div class="fc-gallery-layout">
|
||||||
<div class="fc-gallery-layout__main">
|
<div class="fc-gallery-layout__main">
|
||||||
<PostInfoHeader />
|
<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" />
|
<EmptyState v-if="store.isEmpty" />
|
||||||
<GalleryGrid v-else @open="openImage" />
|
<GalleryGrid v-else @open="openImage" />
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +31,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, watch } from 'vue'
|
import { onMounted, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useGalleryStore } from '../stores/gallery.js'
|
import { useGalleryStore } from '../stores/gallery.js'
|
||||||
import { useModalStore } from '../stores/modal.js'
|
import { useModalStore } from '../stores/modal.js'
|
||||||
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
||||||
@@ -38,6 +45,7 @@ const store = useGalleryStore()
|
|||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
const sel = useGallerySelectionStore()
|
const sel = useGallerySelectionStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const postId = parseInt(route.query.post_id, 10)
|
const postId = parseInt(route.query.post_id, 10)
|
||||||
@@ -63,6 +71,14 @@ watch(() => route.query.post_id, (q) => {
|
|||||||
function openImage(id) {
|
function openImage(id) {
|
||||||
modal.open(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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -72,6 +88,7 @@ function openImage(id) {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.fc-gallery-layout__main { flex: 1; min-width: 0; }
|
.fc-gallery-layout__main { flex: 1; min-width: 0; }
|
||||||
|
.fc-gallery-activefilter { margin-bottom: 12px; }
|
||||||
.fc-gallery-layout__sidebar { flex-shrink: 0; }
|
.fc-gallery-layout__sidebar { flex-shrink: 0; }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
placeholder="Search tags" clearable style="max-width: 320px;"
|
placeholder="Search tags" clearable style="max-width: 320px;"
|
||||||
/>
|
/>
|
||||||
<v-chip-group
|
<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">
|
<v-chip v-for="k in KINDS" :key="k" :value="k" filter size="small">
|
||||||
{{ k }}
|
{{ k }}
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ describe('gallery store: tag/post filter exclusivity', () => {
|
|||||||
expect(scrollCall).not.toContain('tag_id=')
|
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 () => {
|
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||||
const s = useGalleryStore()
|
const s = useGalleryStore()
|
||||||
const urls = []
|
const urls = []
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ async def _mk(client, name, kind, fandom_id=None):
|
|||||||
return (await r.get_json())["id"]
|
return (await r.get_json())["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_tag_returns_shape_and_404(client):
|
||||||
|
tid = await _mk(client, "Lookup", "general")
|
||||||
|
resp = await client.get(f"/api/tags/{tid}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["id"] == tid
|
||||||
|
assert body["name"] == "Lookup"
|
||||||
|
assert body["kind"] == "general"
|
||||||
|
resp = await client.get("/api/tags/99999999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_patch_sets_and_clears_character_fandom(client):
|
async def test_patch_sets_and_clears_character_fandom(client):
|
||||||
fandom = await _mk(client, "Bleach", "fandom")
|
fandom = await _mk(client, "Bleach", "fandom")
|
||||||
|
|||||||
Reference in New Issue
Block a user