Five small G5 findings from the 2026-06-02 audit. Each is local and
follows an established FC pattern.
- download_service: replace hardcoded ('discord','pixiv') tuple with
auth_type_for(platform) == 'token'. A 7th token-platform now picks
up the right credential path without touching this site.
- /api/tags/<source_id>/merge enqueues recompute_centroid.delay after
merge so the target's centroid reflects its new image set
immediately. Daily list_drifted catches it within 24h, but eager
recompute closes the suggestion-quality dip in the meantime.
- backfill_thumbnails added to beat_schedule (daily). The task
docstring claimed periodic Beat but the entry was never registered,
so the library got no self-healing thumbnail repair; only the
manual admin-UI button fired it.
- modal.createAndAdd pushes a kind='fandom' tag into
tagsStore.fandomCache so FandomPicker sees the new fandom on next
open. Was: cache-gated load (length===0) skipped refetch, new
fandom invisible until full page reload.
- cleanup cluster:
- Drop .webp from cleanup_service.unlink — thumbnailer only writes
.jpg/.png; the third tuple member was dead code.
- Drop effective_date from /api/gallery/scroll response — no FE
consumer reads it. Service still computes the attribute for
timeline ordering; this just trims the JSON.
- Rename store.recentMinute → store.recentRuns across the
systemActivity store + three consumers (SystemActivitySummary,
QueuesTable, SystemActivityTab). The data is the last 200 runs
(not actually "last minute"), so the name lied.
NOT in this bundle: the duplicate tag-merge endpoint
(/api/tags vs /api/admin/tags) is harder — has 1 FE caller and 3 tests
on the admin variant; consolidation is its own change.
195 lines
7.1 KiB
JavaScript
195 lines
7.1 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()
|
|
|
|
// Post-scoped cycle. When set, prev/next cycles within this array
|
|
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
|
|
// null, prev/next falls back to current.value.neighbors (the
|
|
// gallery-store-driven /api/gallery/image/<id> 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,
|
|
}
|
|
})
|