Files
FabledCurator/frontend/src/stores/explore.js
T
bvandeusen 2d1cddd9b7
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m18s
feat(explore): 3-pane tagging workspace — gallery | viewer | tag rail
Reworks Explore from "anchor + neighbour grid + cluster tag-gap rail" into a
persistent 3-pane workspace that unfolds the image modal so you can tag while
rabbit-holing (operator concept 2026-06-26):

- LEFT  neighbour grid (larger thumbs), click = walk; breadcrumb retained.
- CENTER light viewer — reuses ImageCanvas + ImageMetaBar(:image) for the
  focused image; "Open full viewer" still launches the overlay modal.
- RIGHT  the modal's TagPanel, hosted on the anchor for modal-parity tagging
  (chips, autocomplete, suggestions + Accept, fandom-on-chip, T/"/" focus).

Reuse without destabilising the audited modal store: TagPanel and
SuggestionsPanel gain an optional `host` prop (default = modal store, so the
image modal is unchanged); the explore store implements the same small
tag-CRUD surface (current/currentImageId + reloadTags/addExistingTag/
removeTag/createAndAdd) over the anchor. ImageMetaBar gains an optional
`image` prop for the same reason.

Drops the mass/cluster tagger (TagGapPanel deleted; clusterIds/thumbById
removed) — per-image tagging feeds the per-tag reference-embedding centroid
better than bulk ops.

Nav: keep the Explore tab but bare /explore now SEEDS a random image
(GET /api/showcase?limit=1 → /explore/:id) so the tab kick-starts a rabbit
hole; explicit meta.navOrder pins nav order (Explore after Gallery) since
router.getRoutes() doesn't preserve declaration order.

Note: the backend cluster tag-gaps route/service (#94a) is now frontend-orphaned
— left in place; flag for a separate cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 01:15:11 -04:00

155 lines
5.7 KiB
JavaScript

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 = 24
export const useExploreStore = defineStore('explore', () => {
const api = useApi()
const anchor = ref(null) // /api/gallery/image/<id> payload
const neighbors = ref([]) // [{id, thumbnail_url, ...}]
const breadcrumb = ref([]) // [{id, thumbnail_url}] walked path
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
}
}
// Forward walk appends; navigating to an id already in the trail (a
// breadcrumb click, or a loop back) TRIMS to it — so the route stays the
// single source of truth and the crumb bar never grows stale branches.
function _reconcileTrail (id, thumbnailUrl) {
const idx = breadcrumb.value.findIndex((c) => c.id === id)
if (idx >= 0) breadcrumb.value = breadcrumb.value.slice(0, idx + 1)
else breadcrumb.value = [...breadcrumb.value, { id, thumbnail_url: thumbnailUrl }]
}
function reset () {
inflight.cancel()
anchor.value = null
neighbors.value = []
breadcrumb.value = []
error.value = null
loading.value = false
}
// --- TagPanel "host" surface ---------------------------------------------
// The anchor IS the current image (same /api/gallery/image/<id> 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, loading, error, NEIGHBOR_LIMIT,
anchorOn, reset,
// host surface
current, currentImageId,
reloadTags, addExistingTag, removeTag, createAndAdd, close,
}
})