feat(gallery): visual 'more like this' UI (Phase 3 frontend)
Modal 'Related' strip (RelatedStrip.vue) — top-12 similar thumbs, fetched on its own DEFERRED, single-flighted path (200ms after the modal is up) so it never blocks or slows the modal; collapses silently on empty/slow/error and is hidden when the image has no embedding (has_embedding flag). 'See all similar' closes the modal and navigates the gallery to ?similar_to=<id>. Gallery store: similar_to filter field + loadSimilar() (ranked, hasMore=false, no timeline); applyFilterFromQuery routes similar-mode to /similar with the scope filters composed; cloneFilter/filterToQuery carry similar_to. Filter bar: clearable 'Similar to #id' chip, sort hidden in similar-mode; timeline sidebar hidden too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user