Files
FabledCurator/frontend/src/stores/gallery.js
T
bvandeusen c22f37d64d
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
feat(gallery): sort by earliest post date across all posts (new default)
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
2026-07-01 10:46:09 -04:00

332 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}