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() // Post-scoped cycle. When set, prev/next cycles within this array // (used by PostCard image clicks — the modal is scoped to that post's // images). When null, prev/next falls back to current.value.neighbors // (the gallery-store-driven /api/gallery/image/ neighbors). const postImageIds = ref(null) const postImageIndex = 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.postImageIds != null) { postImageIds.value = opts.postImageIds postImageIndex.value = opts.postImageIds.indexOf(id) if (postImageIndex.value < 0) postImageIndex.value = 0 } else if (opts.clearPostScope !== false && postImageIds.value != null) { postImageIds.value = null postImageIndex.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 postImageIds.value = null postImageIndex.value = 0 } async function goPrev () { if (postImageIds.value != null) { if (postImageIndex.value > 0) { const newIdx = postImageIndex.value - 1 const newId = postImageIds.value[newIdx] postImageIndex.value = newIdx await open(newId, { postImageIds: postImageIds.value }) } return } if (current.value && current.value.neighbors?.prev_id) { await open(current.value.neighbors.prev_id) } } async function goNext () { if (postImageIds.value != null) { if (postImageIndex.value < postImageIds.value.length - 1) { const newIdx = postImageIndex.value + 1 const newId = postImageIds.value[newIdx] postImageIndex.value = newIdx await open(newId, { postImageIds: postImageIds.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) } const isOpen = computed(() => currentImageId.value !== null) const canPrev = computed(() => { if (postImageIds.value != null) return postImageIndex.value > 0 return current.value?.neighbors?.prev_id != null }) const canNext = computed(() => { if (postImageIds.value != null) { return postImageIndex.value < (postImageIds.value.length - 1) } return current.value?.neighbors?.next_id != null }) return { currentImageId, current, loading, error, postImageIds, postImageIndex, isOpen, canPrev, canNext, open, close, goPrev, goNext, reloadTags, removeTag, addExistingTag, createAndAdd, } })