c22f37d64d
The gallery's newest/oldest sort keys off image_record.effective_date = COALESCE(primary post's post_date, created_at). The primary post is often the repost/download the file came from, so the grid led with download dates rather than when content was first posted (operator-flagged). Add a second materialized sort key, earliest_post_date = MIN(post_date) across ALL of an image's provenance posts (every post it appears in), else created_at — the original publish date. Mirrors the effective_date pattern so the sort stays a forward index scan. - alembic 0071: add earliest_post_date + index (DESC, id DESC); backfill created_at baseline then MIN over image_provenance ⋈ post. - importer: recompute earliest_post_date whenever a dated post is linked (MIN over the image's provenance, which now includes the just-added row). - gallery_service: new sorts posted_new / posted_old key off earliest_post_date; cursor + year/month grouping follow the active column transparently. - api: accept posted_new|posted_old; DEFAULT is now posted_new so the grid leads with original publish date. newest/oldest (effective_date) still available. - frontend: sort dropdown gains "Newest/Oldest post date" (default Newest post date); existing effective-date sorts relabelled "Newest/Oldest added". - tests: service test asserts posted_new/posted_old key off earliest_post_date; frontend default-sort omission test updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
332 lines
13 KiB
JavaScript
332 lines
13 KiB
JavaScript
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 `<img>` 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: 'posted_new', post_id: null,
|
||
// #6 structured tag filter (AND-of-OR + exclude). tag_ids are the AND
|
||
// "include" singletons (light editor + back-compat); tag_or is a list of
|
||
// OR-groups (each group ORs, groups AND); tag_exclude is the NOT set.
|
||
tag_or: [], tag_exclude: [],
|
||
// 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(',')
|
||
const orParam = _orGroupsParam(f.tag_or)
|
||
if (orParam.length) params.tag_or = orParam
|
||
if (f.tag_exclude.length) params.tag_not = f.tag_exclude.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(',')
|
||
const orParam = _orGroupsParam(filter.value.tag_or)
|
||
if (orParam.length) p.tag_or = orParam // array → repeated tag_or= key
|
||
if (filter.value.tag_exclude.length) p.tag_not = filter.value.tag_exclude.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 !== 'posted_new') 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),
|
||
tag_or: _parseGroups(q.tag_or),
|
||
tag_exclude: _parseIds(q.tag_not),
|
||
artist_id: _toId(q.artist_id),
|
||
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
|
||
sort: ['newest', 'oldest', 'posted_new', 'posted_old'].includes(q.sort) ? q.sort : 'posted_new',
|
||
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)
|
||
}
|
||
// tag_or arrives from the URL as a string (one group) or string[] (many);
|
||
// each value is a comma-separated OR-group. Normalize to ids-of-ids, dropping
|
||
// empties so a stray `tag_or=` can't become a match-nothing group.
|
||
function _parseGroups(raw) {
|
||
if (raw == null) return []
|
||
const values = Array.isArray(raw) ? raw : [raw]
|
||
return values.map(_parseIds).filter((g) => g.length)
|
||
}
|
||
|
||
// 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() {
|
||
// Every tag id referenced anywhere in the structured filter needs a chip
|
||
// label — includes, every OR-group member, and excludes.
|
||
const allTagIds = new Set([
|
||
...filter.value.tag_ids,
|
||
...filter.value.tag_or.flat(),
|
||
...filter.value.tag_exclude,
|
||
])
|
||
for (const id of allTagIds) {
|
||
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],
|
||
tag_or: (f.tag_or || []).map((g) => [...g]), // deep-clone the groups
|
||
tag_exclude: [...(f.tag_exclude || [])],
|
||
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(',')
|
||
const orParam = _orGroupsParam(f.tag_or)
|
||
if (orParam.length) q.tag_or = orParam // array → repeated query key
|
||
if (f.tag_exclude?.length) q.tag_not = f.tag_exclude.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 !== 'posted_new') 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
|
||
}
|
||
|
||
// Serialize OR-groups to repeated-param form: [[1,2],[3]] → ['1,2','3'],
|
||
// dropping empty groups. Shared by the store's activeFilterParam and the
|
||
// router-facing filterToQuery so both write tag_or the same way.
|
||
function _orGroupsParam(groups) {
|
||
return (groups || [])
|
||
.map((g) => (g || []).join(','))
|
||
.filter((s) => s.length)
|
||
}
|
||
|
||
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
|
||
}
|