import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' import { useInflightToken } from '../composables/useInflightToken.js' import { toast } from '../utils/toast.js' // Explore (#94): anchor on an image, walk its visual neighbours (pgvector // SigLIP via /api/gallery/similar), keep an in-memory breadcrumb of the walked // path. The ROUTE (`/explore/:imageId`) is the source of truth for the anchor; // the view calls anchorOn() on every param change and the store reconciles the // trail. The store ALSO acts as a TagPanel "host" (current/currentImageId + // tag CRUD over the anchor) so the Explore workspace reuses the modal's tag // rail verbatim for modal-parity tagging while rabbit-holing. const NEIGHBOR_LIMIT = 40 // a wider pool → more variety to browse + jump into export const useExploreStore = defineStore('explore', () => { const api = useApi() const anchor = ref(null) // /api/gallery/image/ payload const neighbors = ref([]) // [{id, thumbnail_url, ...}] const breadcrumb = ref([]) // [{id, thumbnail_url}] walked path // Index of the current anchor within breadcrumb — browser-style back/forward. // The trail keeps its forward branch when you step back (so ← / → can move // through visited items); a NEW walk off a back-step truncates that branch. const cursor = ref(-1) const loading = ref(false) const error = ref(null) const inflight = useInflightToken() async function anchorOn (id) { const numId = Number(id) if (!Number.isInteger(numId) || numId <= 0) return inflight.cancel() const t = inflight.claim() loading.value = true error.value = null try { const detail = await api.get(`/api/gallery/image/${numId}`) if (!t.isCurrent()) return anchor.value = detail _reconcileTrail(numId, detail.thumbnail_url) // Videos / not-yet-embedded images have no neighbours — leave the grid // empty and let the view explain why (anchor.has_embedding === false). if (detail.has_embedding) { const body = await api.get('/api/gallery/similar', { params: { similar_to: numId, limit: NEIGHBOR_LIMIT }, }) if (!t.isCurrent()) return neighbors.value = body.images || [] } else { neighbors.value = [] } } catch (e) { if (t.isCurrent()) { error.value = e.message; neighbors.value = [] } } finally { if (t.isCurrent()) loading.value = false } } // The route is the source of truth; this reconciles the trail + cursor to it. // Revisiting an id already on the path (← / →, a crumb click, or a loop) just // MOVES the cursor there — the forward branch is preserved so → can return to // it. A genuinely new image off a back-step truncates the stale forward branch // (browser semantics), then appends and points the cursor at the new tip. function _reconcileTrail (id, thumbnailUrl) { const idx = breadcrumb.value.findIndex((c) => c.id === id) if (idx >= 0) { cursor.value = idx return } const base = cursor.value < breadcrumb.value.length - 1 ? breadcrumb.value.slice(0, cursor.value + 1) : breadcrumb.value breadcrumb.value = [...base, { id, thumbnail_url: thumbnailUrl }] cursor.value = breadcrumb.value.length - 1 } // ← target: the previous crumb (null at the start of the trail). function backTarget () { return cursor.value > 0 ? breadcrumb.value[cursor.value - 1].id : null } // → target: after a ←, walk forward through the already-visited trail // (browser-style). Otherwise jump to a varied neighbour to keep the // rabbit-hole going — null if neither exists. function forwardTarget () { if (cursor.value >= 0 && cursor.value < breadcrumb.value.length - 1) { return breadcrumb.value[cursor.value + 1].id } if (!neighbors.value.length) return null // Prefer UNVISITED neighbours so → opens something new instead of landing on // a crumb (which snaps the cursor back into the trail — the "loops back" // report). Fall back to the full set only if every neighbour's been seen. const seen = new Set(breadcrumb.value.map((c) => c.id)) const pool = neighbors.value.filter((n) => !seen.has(n.id)) const cands = pool.length ? pool : neighbors.value // The list is already pHash-deduped + MMR-diversified server-side (it spans // clusters, not 40 near-dupes), so a plain random pick gives real variance — // no need to skip the nearest slice the way the raw nearest-list required. return cands[Math.floor(Math.random() * cands.length)].id } function reset () { inflight.cancel() anchor.value = null neighbors.value = [] breadcrumb.value = [] cursor.value = -1 error.value = null loading.value = false } // --- TagPanel "host" surface --------------------------------------------- // The anchor IS the current image (same /api/gallery/image/ payload the // modal uses), so these mirror the modal store's tag-CRUD, targeting the // anchor. Kept separate from the modal store so the audited overlay flow is // untouched; the id is captured at call-time so a fast walk can't misroute a // mutation to the next anchor (same guard as the modal store). const current = computed(() => anchor.value) const currentImageId = computed(() => anchor.value?.id ?? null) async function reloadTags () { const id = anchor.value?.id if (!id) return const tags = await api.get(`/api/images/${id}/tags`) if (anchor.value && anchor.value.id === id) anchor.value.tags = tags } async function addExistingTag (tagId) { const id = anchor.value?.id if (!id) return await api.post(`/api/images/${id}/tags`, { body: { tag_id: tagId, source: 'manual' }, }) await reloadTags() } async function removeTag (tagId) { const id = anchor.value?.id if (!id) return const prev = anchor.value.tags anchor.value.tags = (anchor.value.tags || []).filter((t) => t.id !== tagId) try { await api.delete(`/api/images/${id}/tags/${tagId}`) } catch (e) { if (anchor.value && anchor.value.id === id) anchor.value.tags = prev toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' }) throw e } // The dismiss is best-effort — the tag is gone server-side regardless. try { await api.post(`/api/images/${id}/suggestions/dismiss`, { body: { tag_id: tagId }, }) } catch (e) { toast({ text: `Tag removed, but failed to dismiss suggestion: ${e.message}`, type: 'warning', }) } } async function createAndAdd ({ name, kind, fandom_id = null }) { const id = anchor.value?.id if (!id) return const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } }) if (kind === 'fandom') { // Mirror the modal store: a freshly-created fandom must land in the cache // so FandomPicker sees it without a reload. const { useTagStore } = await import('./tags.js') useTagStore().fandomCache.push({ id: tag.id, name: tag.name, kind: 'fandom', fandom_id: null, fandom_name: null, image_count: 0, }) } if (anchor.value?.id !== id) return // walked away mid-create await addExistingTag(tag.id) } // No overlay to dismiss in the Explore workspace — the chip-body "show me // more of this tag" navigation just routes away. Present so TagPanel's // host.close?.() is a no-op here. function close () {} return { anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, anchorOn, reset, backTarget, forwardTarget, // host surface current, currentImageId, reloadTags, addExistingTag, removeTag, createAndAdd, close, } })