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>
This commit is contained in:
@@ -2,12 +2,15 @@ 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, and surface cluster-consensus tag gaps for the current set. 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.
|
||||
// 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', () => {
|
||||
@@ -69,24 +72,83 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// The consensus set = the anchor plus its neighbours.
|
||||
const clusterIds = computed(() => {
|
||||
const ids = anchor.value ? [anchor.value.id] : []
|
||||
return ids.concat(neighbors.value.map((n) => n.id))
|
||||
})
|
||||
// --- 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)
|
||||
|
||||
// Quick id→thumbnail lookup so the gap panel can render the "missing"
|
||||
// images without another fetch (they're already in the cluster).
|
||||
const thumbById = computed(() => {
|
||||
const map = {}
|
||||
if (anchor.value) map[anchor.value.id] = anchor.value.thumbnail_url
|
||||
for (const n of neighbors.value) map[n.id] = n.thumbnail_url
|
||||
return map
|
||||
})
|
||||
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,
|
||||
clusterIds, thumbById, NEIGHBOR_LIMIT,
|
||||
anchor, neighbors, breadcrumb, loading, error, NEIGHBOR_LIMIT,
|
||||
anchorOn, reset,
|
||||
// host surface
|
||||
current, currentImageId,
|
||||
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user