Files
FabledCurator/frontend/src/stores/modal.js
T
bvandeusen 3bf41ecac3
CI / lint (push) Failing after 2s
CI / frontend-build (push) Failing after 17s
CI / backend-lint-and-test (push) Failing after 27s
CI / integration (push) Successful in 3m43s
feat(ui): modal prev/next walks the current gallery filter (#1322)
The image modal cycled GLOBAL neighbours; now the gallery hands it a snapshot of
the currently-filtered, ordered id list so prev/next moves through exactly what
you're viewing — the filtered-playlist behaviour lost in the ImageRepo→FC move.
Generalized the modal store's post-scoped cycle into a `playlistIds` playlist
reused by both GalleryView and PostCard (falls back to global neighbours when no
playlist is passed, e.g. Explore's "open full viewer").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-06 19:02:38 -04:00

199 lines
7.3 KiB
JavaScript

import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const useModalStore = defineStore('modal', () => {
const api = useApi()
const currentImageId = ref(null)
const current = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
// Tag mutations interpolate the image id into the URL after an
// await; without an inflight token, a fast prev/next can route the
// DELETE/POST to the wrong image AND the response to the wrong
// chip rail. Audit 2026-06-02.
const inflight = useInflightToken()
// Scoped playlist. When set, prev/next cycles within THIS ordered id array —
// the current gallery filter (GalleryView) or a post's images (PostCard) — so
// the modal walks exactly what the user was looking at, not a global order.
// When null, prev/next falls back to current.value.neighbors (the
// /api/gallery/image/<id> global neighbours).
const playlistIds = ref(null)
const playlistIndex = ref(0)
async function open (id, opts = {}) {
// Cancel any in-flight tag mutation or reloadTags from the
// previous image so its late response can't apply to this one.
inflight.cancel()
currentImageId.value = id
current.value = null // cleared upfront so it stays null on error
// Update post-scoped state if caller passed it; otherwise clear so
// the next open() from gallery context uses neighbors mode.
if (opts.playlistIds != null) {
playlistIds.value = opts.playlistIds
playlistIndex.value = opts.playlistIds.indexOf(id)
if (playlistIndex.value < 0) playlistIndex.value = 0
} else if (opts.clearPlaylist !== false && playlistIds.value != null) {
playlistIds.value = null
playlistIndex.value = 0
}
const t = inflight.claim()
await run(async () => {
const body = await api.get(`/api/gallery/image/${id}`)
if (!t.isCurrent()) return
current.value = body
})
}
async function close () {
inflight.cancel()
currentImageId.value = null
current.value = null
error.value = null
playlistIds.value = null
playlistIndex.value = 0
}
async function goPrev () {
if (playlistIds.value != null) {
if (playlistIndex.value > 0) {
const newIdx = playlistIndex.value - 1
const newId = playlistIds.value[newIdx]
playlistIndex.value = newIdx
await open(newId, { playlistIds: playlistIds.value })
}
return
}
if (current.value && current.value.neighbors?.prev_id) {
await open(current.value.neighbors.prev_id)
}
}
async function goNext () {
if (playlistIds.value != null) {
if (playlistIndex.value < playlistIds.value.length - 1) {
const newIdx = playlistIndex.value + 1
const newId = playlistIds.value[newIdx]
playlistIndex.value = newIdx
await open(newId, { playlistIds: playlistIds.value })
}
return
}
if (current.value && current.value.neighbors?.next_id) {
await open(current.value.neighbors.next_id)
}
}
async function reloadTags () {
// Capture image id at call-time. After an await, currentImageId
// may have advanced via prev/next navigation; without capture, the
// GET would target the new image and write its tags onto a chip
// rail the user didn't open. Audit 2026-06-02.
const imageId = currentImageId.value
if (!imageId) return
const t = inflight.claim()
const tags = await api.get(`/api/images/${imageId}/tags`)
if (!t.isCurrent()) return
// Only commit if the modal is still showing this image — guards
// against close() / nav clearing current between the await and now.
if (current.value && currentImageId.value === imageId) {
current.value.tags = tags
}
}
async function removeTag (tagId) {
const imageId = currentImageId.value
if (!imageId) return
const prev = current.value.tags
// Optimistic UI: drop the chip immediately.
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
// Split the two POSTs so a dismiss failure (secondary side-effect)
// doesn't roll back the successful DELETE — previously the catch
// unconditionally restored the chip rail even when only the
// dismiss had failed, so the UI lied until refresh. Audit 2026-06-02.
try {
await api.delete(`/api/images/${imageId}/tags/${tagId}`)
} catch (e) {
// Real failure: roll back, surface, rethrow.
if (current.value && currentImageId.value === imageId) {
current.value.tags = prev
}
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
throw e
}
// DELETE landed. The dismiss is fire-and-best-effort — log on
// failure but DON'T roll back the chip rail; the tag is gone
// server-side regardless.
try {
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: tagId },
})
} catch (e) {
toast({
text: `Tag removed, but failed to dismiss suggestion: ${e.message}`,
type: 'warning',
})
}
}
async function addExistingTag (tagId) {
const imageId = currentImageId.value
if (!imageId) return
await api.post(`/api/images/${imageId}/tags`, {
body: { tag_id: tagId, source: 'manual' },
})
await reloadTags()
}
async function createAndAdd ({ name, kind, fandom_id = null }) {
// Capture imageId so the post-create reloadTags / addExistingTag
// flow stays bound to the image the user clicked on, even if
// they navigated during the /api/tags POST.
const imageId = currentImageId.value
if (!imageId) return
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
// Audit 2026-06-02: a kind='fandom' created here used to be
// invisible to FandomPicker until a full page reload — its load
// gates on fandomCache.length, so a non-empty cache skips the
// refetch and the new fandom never appears. Push it into the
// cache directly so the next open sees it.
if (kind === 'fandom') {
const { useTagStore } = await import('./tags.js')
const tagStore = useTagStore()
tagStore.fandomCache.push({
id: tag.id, name: tag.name, kind: 'fandom',
fandom_id: null, fandom_name: null, image_count: 0,
})
}
if (currentImageId.value !== imageId) return // navigated away
await addExistingTag(tag.id)
// Returned so TagPanel can match the created tag against a pending
// suggestion and record the acceptance (manual add == accept, 2026-07-03).
return tag
}
const isOpen = computed(() => currentImageId.value !== null)
const canPrev = computed(() => {
if (playlistIds.value != null) return playlistIndex.value > 0
return current.value?.neighbors?.prev_id != null
})
const canNext = computed(() => {
if (playlistIds.value != null) {
return playlistIndex.value < (playlistIds.value.length - 1)
}
return current.value?.neighbors?.next_id != null
})
return {
currentImageId, current, loading, error,
playlistIds, playlistIndex,
isOpen, canPrev, canNext,
open, close, goPrev, goNext,
reloadTags, removeTag, addExistingTag, createAndAdd,
}
})