import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' import { useInflightToken } from '../composables/useInflightToken.js' // Initial paint is a SINGLE request (INITIAL_LIMIT). The old 10×serial- // batch loop (2026-05-30) only staggered METADATA, which isn't the visual // bottleneck — thumbnails load as independent `` requests, and // GalleryItem now reveals each tile on its own image `@load`. One fetch is // far fewer round-trips and faster to first paint; the reveal-on-load is // what makes appearance progressive. Infinite scroll pulls PAGE per trigger. // Reworked 2026-06-04. const PAGE = 25 const INITIAL_LIMIT = 50 export const useGalleryStore = defineStore('gallery', () => { const api = useApi() const images = ref([]) const dateGroups = ref([]) // [{year, month, image_ids}] const nextCursor = ref(null) const loading = ref(false) const error = ref(null) const filter = ref({ tag_ids: [], artist_id: null, media_type: null, sort: 'newest', post_id: null, // 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 + // filter change), never on plain scroll. Null until first load. const facets = ref(null) const facetsLoading = ref(false) const facetsInflight = useInflightToken() // Display names for the active filter chips — resolved by id on deep-link // and pre-noted by the filter bar when a user picks from autocomplete. const tagLabels = ref({}) // tagId -> name const artistLabel = ref(null) const timelineBuckets = ref([]) const timelineLoading = ref(false) // Was a hand-rolled inflightId counter; the audit-2026-06-02 fan-out // moved this pattern into useInflightToken so every store can share it. const inflight = useInflightToken() async function loadInitial() { inflight.cancel() images.value = [] dateGroups.value = [] nextCursor.value = null await loadMore(INITIAL_LIMIT) } async function loadMore(limit = PAGE) { if (loading.value) return loading.value = true error.value = null const t = inflight.claim() try { const params = { limit, ...activeFilterParam() } if (nextCursor.value) params.cursor = nextCursor.value const body = await api.get('/api/gallery/scroll', { params }) if (!t.isCurrent()) return images.value.push(...body.images) dateGroups.value = mergeGroups(dateGroups.value, body.date_groups) nextCursor.value = body.next_cursor } catch (e) { error.value = e.message } finally { if (t.isCurrent()) loading.value = false } } async function loadTimeline() { timelineLoading.value = true try { const params = activeFilterParam() timelineBuckets.value = await api.get('/api/gallery/timeline', { params }) } finally { timelineLoading.value = false } } // 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 // jump's already-applied state. inflight.cancel() const t = inflight.claim() const params = { year, month, ...activeFilterParam() } const body = await api.get('/api/gallery/jump', { params }) if (!t.isCurrent()) return if (body.cursor) { images.value = [] dateGroups.value = [] nextCursor.value = body.cursor await loadMore() } } function activeFilterParam() { // post_id is the exclusive post-detail view. if (filter.value.post_id) return { post_id: filter.value.post_id } const p = {} if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',') if (filter.value.artist_id) p.artist_id = filter.value.artist_id if (filter.value.media_type) p.media = filter.value.media_type if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort if (filter.value.platform) p.platform = filter.value.platform if (filter.value.untagged) p.untagged = '1' if (filter.value.no_artist) p.no_artist = '1' if (filter.value.date_from) p.date_from = filter.value.date_from if (filter.value.date_to) p.date_to = filter.value.date_to return p } // Live facet counts, scoped to the current filter (panel-gated — callers // are the refine panel only). Single-flighted so rapid toggles don't let a // stale response clobber a newer one. async function loadFacets() { facetsLoading.value = true const t = facetsInflight.claim() try { const body = await api.get('/api/gallery/facets', { params: activeFilterParam() }) if (!t.isCurrent()) return facets.value = body } catch (e) { if (t.isCurrent()) error.value = e.message } finally { if (t.isCurrent()) facetsLoading.value = false } } // URL is the source of truth for filters. GalleryView calls this on mount // and on every route-query change; the filter bar mutates the URL // (router.push) rather than the store directly, so deep-links, the back // button, and bar actions all funnel through one path. async function applyFilterFromQuery(q) { filter.value = { tag_ids: _parseIds(q.tag_id), artist_id: _toId(q.artist_id), media_type: ['image', 'video'].includes(q.media) ? q.media : null, sort: q.sort === 'oldest' ? 'oldest' : 'newest', post_id: _toId(q.post_id), platform: q.platform || null, untagged: _truthy(q.untagged), 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() } _resolveLabels() } function _truthy(v) { return v === '1' || v === 'true' || v === true } function _parseDate(v) { return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null } function _toId(v) { const n = Number(v) return Number.isInteger(n) && n > 0 ? n : null } function _parseIds(raw) { if (!raw) return [] return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0) } // Pre-seed a label so a freshly-picked chip shows its name without a // round-trip; the bar calls these before pushing the new URL. function noteTagLabel(id, name) { tagLabels.value = { ...tagLabels.value, [id]: name } } function noteArtistLabel(name) { artistLabel.value = name || null } async function _resolveLabels() { for (const id of filter.value.tag_ids) { if (tagLabels.value[id]) continue try { const t = await api.get(`/api/tags/${id}`) tagLabels.value = { ...tagLabels.value, [id]: t.name } } catch { /* chip falls back to #id */ } } if (filter.value.artist_id && !artistLabel.value) { // The filtered set is this artist — derive the chip label from a tile. const hit = images.value.find( (i) => i.artist && i.artist.id === filter.value.artist_id ) artistLabel.value = hit?.artist?.name || null } if (!filter.value.artist_id) artistLabel.value = null } const hasMore = computed(() => nextCursor.value !== null) const isEmpty = computed(() => !loading.value && images.value.length === 0 && error.value === null) return { images, dateGroups, hasMore, isEmpty, loading, error, filter, tagLabels, artistLabel, timelineBuckets, timelineLoading, facets, facetsLoading, loadInitial, loadMore, loadTimeline, jumpTo, loadFacets, loadSimilar, applyFilterFromQuery, noteTagLabel, noteArtistLabel, } }) // Shared by GalleryFilterBar and GalleryFacetPanel so both write the URL in // one format. post_id is intentionally absent — the bar/panel are hidden in // the exclusive post-detail view. export function cloneFilter(f) { return { 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, } } export function filterToQuery(f) { const q = {} if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',') if (f.artist_id) q.artist_id = String(f.artist_id) if (f.media_type) q.media = f.media_type if (f.sort && f.sort !== 'newest') q.sort = f.sort if (f.platform) q.platform = f.platform if (f.untagged) q.untagged = '1' 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 } function mergeGroups(existing, incoming) { // Merge sequential groups with the same (year, month) instead of duplicating. const merged = [...existing] for (const g of incoming) { const tail = merged[merged.length - 1] if (tail && tail.year === g.year && tail.month === g.month) { tail.image_ids = [...tail.image_ids, ...g.image_ids] } else { merged.push({ ...g, image_ids: [...g.image_ids] }) } } return merged }