Extracts gallery.js's hand-rolled inflightId pattern into a new useInflightToken composable; adopts in every store that previously had no guard against late-response overwrites or wrong-image URL interpolation. Two operator-impacting bugs the audit (workflow wf_bbe3fdb1-e62) flagged: - modal.removeTag rolled back the chip rail unconditionally even when only the secondary dismiss POST had failed — UI lied until refresh. And all tag-mutation URLs interpolated currentImageId AFTER an await, so a fast prev/next could route DELETE/POST to the wrong image. Both fixed: split try/catch (dismiss failure surfaces a warning, doesn't roll back the delete); imageId captured at call-time and used in URLs throughout. - suggestions.accept dereferenced currentImageId after the awaited POST /api/tags, so the subsequent /suggestions/accept could apply A's chosen tag to image B AND push it to B's allowlist. Fixed by capturing imageId at click-time + inflight guard on load(). Same shape across artist / downloads / artistDirectory / tagDirectory / posts stores: rapid filter/nav changes used to interleave responses (last-writer-wins). Now the late response is discarded and the most-recent request wins. Filter-change-during- search no longer drops the second fetch because the loading flag was still true from the first. gallery.js's inflightId removed in favor of the shared composable so the pattern stays consistent.
171 lines
5.6 KiB
JavaScript
171 lines
5.6 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
import { useApi } from '../composables/useApi.js'
|
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
|
|
|
export const usePostsStore = defineStore('posts', () => {
|
|
const api = useApi()
|
|
|
|
const items = ref([])
|
|
const cursor = ref(null)
|
|
const { loading, error, run } = useAsyncAction()
|
|
const done = ref(false)
|
|
const filters = ref({ artist_id: null, platform: null })
|
|
|
|
// In-context (anchored) view: bidirectional cursors so the feed can load
|
|
// newer posts on scroll-up and older posts on scroll-down around a post.
|
|
const cursorOlder = ref(null)
|
|
const cursorNewer = ref(null)
|
|
const doneOlder = ref(false)
|
|
const doneNewer = ref(false)
|
|
const anchorId = ref(null)
|
|
// loadInitial, loadMore, loadAround, loadOlder, loadNewer all share
|
|
// one `loading` flag and previously had no inflight guard. A filter
|
|
// change (loadInitial) racing a still-in-flight loadMore would
|
|
// append the prior filter's items into the new filter's feed.
|
|
// Audit 2026-06-02.
|
|
const inflight = useInflightToken()
|
|
|
|
function _qs() {
|
|
const q = {}
|
|
if (cursor.value) q.cursor = cursor.value
|
|
if (filters.value.artist_id != null) q.artist_id = filters.value.artist_id
|
|
if (filters.value.platform) q.platform = filters.value.platform
|
|
return q
|
|
}
|
|
|
|
function _reset() {
|
|
items.value = []
|
|
cursor.value = null
|
|
done.value = false
|
|
error.value = null
|
|
}
|
|
|
|
async function loadInitial(newFilters) {
|
|
inflight.cancel()
|
|
filters.value = {
|
|
artist_id: newFilters?.artist_id ?? null,
|
|
platform: newFilters?.platform ?? null,
|
|
}
|
|
_reset()
|
|
await loadMore()
|
|
}
|
|
|
|
async function loadMore() {
|
|
if (loading.value || done.value) return
|
|
const t = inflight.claim()
|
|
await run(async () => {
|
|
const body = await api.get('/api/posts', { params: _qs() })
|
|
if (!t.isCurrent()) return
|
|
items.value.push(...body.items)
|
|
cursor.value = body.next_cursor
|
|
if (body.next_cursor == null) done.value = true
|
|
})
|
|
}
|
|
|
|
async function getPostFull(id) {
|
|
// Used by PostCard's "Show more" expand to fetch the full description.
|
|
return await api.get(`/api/posts/${id}`)
|
|
}
|
|
|
|
// Filter overlay for the around/older/newer (in-context anchored)
|
|
// path. Keep this distinct from `filters.value` (the down-only feed)
|
|
// so a normal-feed filter change doesn't leak into an active anchored
|
|
// view (or vice versa). Caller of loadAround passes the snapshot; the
|
|
// subsequent loadOlder/loadNewer use it verbatim.
|
|
function _aroundParams(extra) {
|
|
const p = { ...extra }
|
|
if (filters.value.artist_id != null) p.artist_id = filters.value.artist_id
|
|
if (filters.value.platform) p.platform = filters.value.platform
|
|
return p
|
|
}
|
|
|
|
// Load a window centered on `postId`: newer posts above, the post, older
|
|
// posts below. Sets both directional cursors for subsequent scrolling.
|
|
// Accepts the same filter shape as loadInitial so the anchored view
|
|
// stays artist/platform-scoped (operator-flagged 2026-06-01: clicking a
|
|
// post title from the modal's Provenance card opens the post in the
|
|
// posts feed; without this the older/newer scroll loaded unfiltered
|
|
// global posts instead of staying in the artist's stream).
|
|
async function loadAround(postId, newFilters) {
|
|
inflight.cancel()
|
|
filters.value = {
|
|
artist_id: newFilters?.artist_id ?? null,
|
|
platform: newFilters?.platform ?? null,
|
|
}
|
|
loading.value = true
|
|
error.value = null
|
|
anchorId.value = null
|
|
const t = inflight.claim()
|
|
try {
|
|
const body = await api.get('/api/posts', {
|
|
params: _aroundParams({ around: postId }),
|
|
})
|
|
if (!t.isCurrent()) return
|
|
items.value = body.items
|
|
cursorOlder.value = body.cursor_older
|
|
cursorNewer.value = body.cursor_newer
|
|
doneOlder.value = body.cursor_older == null
|
|
doneNewer.value = body.cursor_newer == null
|
|
anchorId.value = body.anchor_id
|
|
} catch (e) {
|
|
if (!t.isCurrent()) return
|
|
error.value = e
|
|
} finally {
|
|
if (t.isCurrent()) loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadOlder() {
|
|
if (loading.value || doneOlder.value || cursorOlder.value == null) return
|
|
loading.value = true
|
|
const t = inflight.claim()
|
|
try {
|
|
const body = await api.get('/api/posts', {
|
|
params: _aroundParams({
|
|
cursor: cursorOlder.value, direction: 'older',
|
|
}),
|
|
})
|
|
if (!t.isCurrent()) return
|
|
items.value.push(...body.items)
|
|
cursorOlder.value = body.next_cursor
|
|
if (body.next_cursor == null) doneOlder.value = true
|
|
} catch (e) {
|
|
if (!t.isCurrent()) return
|
|
error.value = e
|
|
} finally {
|
|
if (t.isCurrent()) loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadNewer() {
|
|
if (loading.value || doneNewer.value || cursorNewer.value == null) return
|
|
loading.value = true
|
|
const t = inflight.claim()
|
|
try {
|
|
const body = await api.get('/api/posts', {
|
|
params: _aroundParams({
|
|
cursor: cursorNewer.value, direction: 'newer',
|
|
}),
|
|
})
|
|
if (!t.isCurrent()) return
|
|
items.value.unshift(...body.items)
|
|
cursorNewer.value = body.next_cursor
|
|
if (body.next_cursor == null) doneNewer.value = true
|
|
} catch (e) {
|
|
if (!t.isCurrent()) return
|
|
error.value = e
|
|
} finally {
|
|
if (t.isCurrent()) loading.value = false
|
|
}
|
|
}
|
|
|
|
return {
|
|
items, cursor, loading, done, error, filters,
|
|
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
|
|
loadInitial, loadMore, getPostFull,
|
|
loadAround, loadOlder, loadNewer,
|
|
}
|
|
})
|