diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue index a19063f..9e53a75 100644 --- a/frontend/src/components/gallery/GalleryFilterBar.vue +++ b/frontend/src/components/gallery/GalleryFilterBar.vue @@ -38,6 +38,12 @@ prepend-icon="mdi-account" @click:close="clearArtist" >{{ store.artistLabel || `Artist #${store.filter.artist_id}` }} + Similar to #{{ store.filter.similar_to }} @@ -52,7 +58,9 @@ Videos + store.filter.artist_id != null || store.filter.media_type != null || store.filter.sort !== 'newest' || + store.filter.similar_to != null || hasRefineFilters.value ) @@ -190,6 +199,7 @@ function clearArtist() { store.noteArtistLabel(null) pushFilter((n) => { n.artist_id = null }) } +function clearSimilar() { pushFilter((n) => { n.similar_to = null }) } function setMedia(m) { pushFilter((n) => { n.media_type = m }) } function setSort(s) { pushFilter((n) => { n.sort = s }) } function clearAll() { router.push({ name: 'gallery', query: {} }) } diff --git a/frontend/src/components/modal/ImageViewer.vue b/frontend/src/components/modal/ImageViewer.vue index 4cb8fe5..1529bbc 100644 --- a/frontend/src/components/modal/ImageViewer.vue +++ b/frontend/src/components/modal/ImageViewer.vue @@ -51,6 +51,9 @@ @@ -65,6 +68,7 @@ import ImageCanvas from './ImageCanvas.vue' import VideoCanvas from './VideoCanvas.vue' import TagPanel from './TagPanel.vue' import ProvenancePanel from './ProvenancePanel.vue' +import RelatedStrip from './RelatedStrip.vue' const emit = defineEmits(['close']) diff --git a/frontend/src/components/modal/RelatedStrip.vue b/frontend/src/components/modal/RelatedStrip.vue new file mode 100644 index 0000000..7264df4 --- /dev/null +++ b/frontend/src/components/modal/RelatedStrip.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js index 39169f7..bad0d8b 100644 --- a/frontend/src/stores/gallery.js +++ b/frontend/src/stores/gallery.js @@ -27,6 +27,9 @@ export const useGalleryStore = defineStore('gallery', () => { // Phase-2 faceted refine params. platform: null, untagged: false, no_artist: false, date_from: null, date_to: null, + // Phase-3 visual similarity: when set, the gallery is in "similar mode" — + // ranked by cosine distance to this image, bounded top-N, no cursor. + similar_to: null, }) // Live facet counts for the refine panel; fetched on-demand (panel open + @@ -84,6 +87,39 @@ export const useGalleryStore = defineStore('gallery', () => { } } + // Visual "more like this": ranked top-N by cosine distance, scope filters + // composed (AND). No cursor / no timeline — bounded result set. + async function loadSimilar() { + inflight.cancel() + images.value = [] + dateGroups.value = [] + nextCursor.value = null + timelineBuckets.value = [] + loading.value = true + error.value = null + const t = inflight.claim() + try { + const f = filter.value + const params = { similar_to: f.similar_to, limit: 100 } + if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',') + if (f.artist_id) params.artist_id = f.artist_id + if (f.media_type) params.media = f.media_type + if (f.platform) params.platform = f.platform + if (f.untagged) params.untagged = '1' + if (f.no_artist) params.no_artist = '1' + if (f.date_from) params.date_from = f.date_from + if (f.date_to) params.date_to = f.date_to + const body = await api.get('/api/gallery/similar', { params }) + if (!t.isCurrent()) return + images.value = body.images + // ranked + bounded → no next page (nextCursor stays null → hasMore false) + } catch (e) { + error.value = e.message + } finally { + if (t.isCurrent()) loading.value = false + } + } + async function jumpTo(year, month) { // Rapid timeline-jump clicks need the same race guard as // loadMore — first jump's late body could clobber the second @@ -150,9 +186,15 @@ export const useGalleryStore = defineStore('gallery', () => { no_artist: _truthy(q.no_artist), date_from: _parseDate(q.date_from), date_to: _parseDate(q.date_to), + similar_to: _toId(q.similar_to), + } + if (filter.value.similar_to) { + // Similar mode: ranked, no timeline scroll. + await loadSimilar() + } else { + await loadInitial() + await loadTimeline() } - await loadInitial() - await loadTimeline() _resolveLabels() } @@ -200,7 +242,7 @@ export const useGalleryStore = defineStore('gallery', () => { images, dateGroups, hasMore, isEmpty, loading, error, filter, tagLabels, artistLabel, timelineBuckets, timelineLoading, facets, facetsLoading, - loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, + loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar, applyFilterFromQuery, noteTagLabel, noteArtistLabel, } }) @@ -213,6 +255,7 @@ export function cloneFilter(f) { tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type, sort: f.sort, platform: f.platform, untagged: f.untagged, no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to, + similar_to: f.similar_to, } } @@ -227,6 +270,7 @@ export function filterToQuery(f) { if (f.no_artist) q.no_artist = '1' if (f.date_from) q.date_from = f.date_from if (f.date_to) q.date_to = f.date_to + if (f.similar_to) q.similar_to = String(f.similar_to) return q } diff --git a/frontend/src/views/GalleryView.vue b/frontend/src/views/GalleryView.vue index a264a11..62ddd12 100644 --- a/frontend/src/views/GalleryView.vue +++ b/frontend/src/views/GalleryView.vue @@ -16,7 +16,10 @@ - + diff --git a/frontend/test/components/galleryRelatedStrip.spec.js b/frontend/test/components/galleryRelatedStrip.spec.js new file mode 100644 index 0000000..28ce944 --- /dev/null +++ b/frontend/test/components/galleryRelatedStrip.spec.js @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { flushPromises } from '@vue/test-utils' + +import RelatedStrip from '../../src/components/modal/RelatedStrip.vue' +import { useModalStore } from '../../src/stores/modal.js' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +function stubFetch(handler) { + globalThis.fetch = vi.fn(async (url) => { + const { status, body } = handler(url) + return { + ok: status >= 200 && status < 300, + status, statusText: String(status), + text: async () => JSON.stringify(body), + } + }) +} + +describe('RelatedStrip (modal "more like this")', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() }) + + it('stays hidden and never fetches when the image has no embedding', async () => { + const pinia = freshPinia() + useModalStore().current = { id: 1, has_embedding: false } + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy + const w = mountComponent(RelatedStrip, { pinia }) + await vi.advanceTimersByTimeAsync(500) + expect(fetchSpy).not.toHaveBeenCalled() + expect(w.find('.fc-related').exists()).toBe(false) + }) + + it('deferred-fetches and renders similar thumbs for an embedded image', async () => { + const pinia = freshPinia() + useModalStore().current = { id: 1, has_embedding: true } + stubFetch(() => ({ + status: 200, + body: { images: [{ id: 2, thumbnail_url: '/t2' }, { id: 3, thumbnail_url: '/t3' }] }, + })) + const w = mountComponent(RelatedStrip, { pinia }) + // Nothing before the defer elapses (modal image gets priority). + expect(globalThis.fetch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(250) + await flushPromises() + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + expect(w.findAll('.fc-related__item').length).toBe(2) + }) + + it('collapses silently when the similar fetch returns nothing', async () => { + const pinia = freshPinia() + useModalStore().current = { id: 1, has_embedding: true } + stubFetch(() => ({ status: 200, body: { images: [] } })) + const w = mountComponent(RelatedStrip, { pinia }) + await vi.advanceTimersByTimeAsync(250) + await flushPromises() + expect(w.find('.fc-related').exists()).toBe(false) + }) + + it('clicking a thumb opens that image in the modal', async () => { + const pinia = freshPinia() + const modal = useModalStore() + modal.current = { id: 1, has_embedding: true } + const openSpy = vi.spyOn(modal, 'open').mockResolvedValue() + stubFetch(() => ({ status: 200, body: { images: [{ id: 7, thumbnail_url: '/t7' }] } })) + const w = mountComponent(RelatedStrip, { pinia }) + await vi.advanceTimersByTimeAsync(250) + await flushPromises() + await w.find('.fc-related__item').trigger('click') + expect(openSpy).toHaveBeenCalledWith(7) + }) +}) diff --git a/frontend/test/gallery.spec.js b/frontend/test/gallery.spec.js index c5d52f8..93ce5c6 100644 --- a/frontend/test/gallery.spec.js +++ b/frontend/test/gallery.spec.js @@ -122,6 +122,28 @@ describe('gallery store: composable filter', () => { expect(s.facets.total).toBe(4) }) + it('similar_to routes applyFilterFromQuery to the /similar endpoint, not scroll', async () => { + const s = useGalleryStore() + const urls = [] + stubFetch((url) => { + urls.push(url) + if (url.includes('/api/gallery/similar')) { + return { status: 200, body: { images: [{ id: 9 }], next_cursor: null, date_groups: [] } } + } + return { status: 200, body: EMPTY } + }) + await s.applyFilterFromQuery({ similar_to: '5', tag_id: '3' }) + expect(s.filter.similar_to).toBe(5) + const sim = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/similar'))) + expect(sim).toContain('similar_to=5') + expect(sim).toContain('limit=100') + expect(sim).toContain('tag_id=3') // scope composes + expect(s.images.map((i) => i.id)).toEqual([9]) + expect(s.hasMore).toBe(false) // ranked + bounded → no pages + expect(urls.some((u) => u.includes('/api/gallery/scroll'))).toBe(false) + expect(urls.some((u) => u.includes('/api/gallery/timeline'))).toBe(false) + }) + it('loadInitial issues exactly one scroll request at the initial limit', async () => { const s = useGalleryStore() const urls = [] @@ -154,6 +176,11 @@ describe('filterToQuery / cloneFilter', () => { expect(q.sort).toBeUndefined() // newest is the default → omitted }) + it('serializes similar_to', () => { + expect(filterToQuery({ tag_ids: [], similar_to: 42 }).similar_to).toBe('42') + expect(filterToQuery({ tag_ids: [], similar_to: null }).similar_to).toBeUndefined() + }) + it('cloneFilter copies refine fields and detaches tag_ids', () => { const orig = { tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',