diff --git a/frontend/src/components/TopNav.vue b/frontend/src/components/TopNav.vue
index b37514e..4c0028f 100644
--- a/frontend/src/components/TopNav.vue
+++ b/frontend/src/components/TopNav.vue
@@ -72,10 +72,14 @@ import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore()
onMounted(() => system.refreshHealth())
-// Same mechanism the old sidebar used: every route with a meta.title is a
-// nav entry, in router declaration order. Auto-tracks future routes.
+// Every route with a meta.title is a nav entry. Order by meta.navOrder —
+// router.getRoutes() does NOT guarantee declaration order, so explicit numbers
+// pin the sequence (e.g. Explore after Gallery). Routes without one fall to the
+// end. Auto-tracks future routes.
const navRoutes = computed(() =>
- router.getRoutes().filter(r => r.meta?.title)
+ router.getRoutes()
+ .filter(r => r.meta?.title)
+ .sort((a, b) => (a.meta.navOrder ?? 999) - (b.meta.navOrder ?? 999))
)
// Content links for the centered desktop row — everything EXCEPT Settings,
// which is config and gets pinned to the right edge instead.
diff --git a/frontend/src/components/explore/TagGapPanel.vue b/frontend/src/components/explore/TagGapPanel.vue
deleted file mode 100644
index f44546c..0000000
--- a/frontend/src/components/explore/TagGapPanel.vue
+++ /dev/null
@@ -1,159 +0,0 @@
-
-
-
Tag-coverage gaps
-
- Tags most of this cluster shares but some images lack. Apply to close the
- gap across the stragglers.
-
-
-
-
-
-
-
-
-
-
-
- {{ error }}
-
-
- Anchor on an image with visual neighbours to see gaps.
-
-
- No gaps at this threshold — this cluster is consistently tagged.
-
- Apply to {{ g.missing_image_ids.length }} missing
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/modal/ImageMetaBar.vue b/frontend/src/components/modal/ImageMetaBar.vue
index b9629ad..5cf3482 100644
--- a/frontend/src/components/modal/ImageMetaBar.vue
+++ b/frontend/src/components/modal/ImageMetaBar.vue
@@ -38,8 +38,12 @@ import { useModalStore } from '../../stores/modal.js'
import { copyText } from '../../utils/clipboard.js'
import { toast } from '../../utils/toast.js'
+// `image` lets a non-modal surface (the Explore workspace) render the same
+// meta + download block for its anchor. Defaults to the modal store's current
+// image so the image modal is unchanged.
+const props = defineProps({ image: { type: Object, default: null } })
const modal = useModalStore()
-const img = computed(() => modal.current)
+const img = computed(() => props.image ?? modal.current)
function humanSize (bytes) {
const units = ['B', 'KB', 'MB', 'GB']
diff --git a/frontend/src/components/modal/SuggestionsPanel.vue b/frontend/src/components/modal/SuggestionsPanel.vue
index 0e40779..d49325c 100644
--- a/frontend/src/components/modal/SuggestionsPanel.vue
+++ b/frontend/src/components/modal/SuggestionsPanel.vue
@@ -51,12 +51,19 @@ import { useModalStore } from '../../stores/modal.js'
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
import AliasPickerDialog from './AliasPickerDialog.vue'
-const props = defineProps({ imageId: { type: Number, required: true } })
+const props = defineProps({
+ imageId: { type: Number, required: true },
+ // The tagging host whose chip rail to refresh after an accept. Defaults to
+ // the modal store (image modal); the Explore workspace passes its anchor host
+ // so the same panel refreshes the right surface. See TagPanel.
+ host: { type: Object, default: null },
+})
// 'accepted' lets the parent return focus to the tag input after a suggestion is
// applied (operator-asked 2026-06-08).
const emit = defineEmits(['accepted'])
const store = useSuggestionsStore()
-const modal = useModalStore()
+const modalStore = useModalStore()
+const host = props.host || modalStore
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
// suggestion categories. Only 'character' remains as a people-style
@@ -84,7 +91,7 @@ watch(() => props.imageId, (id) => {
async function onAccept(s) {
try {
await store.accept(s)
- await modal.reloadTags()
+ await host.reloadTags()
emit('accepted')
} catch (e) {
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
@@ -98,7 +105,7 @@ async function onAliasConfirm(canonicalTagId) {
try {
await store.aliasAccept(aliasTarget.value, canonicalTagId)
aliasDialog.value = false
- await modal.reloadTags()
+ await host.reloadTags()
emit('accepted')
} catch (e) {
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
diff --git a/frontend/src/components/modal/TagPanel.vue b/frontend/src/components/modal/TagPanel.vue
index ff611c8..38febe9 100644
--- a/frontend/src/components/modal/TagPanel.vue
+++ b/frontend/src/components/modal/TagPanel.vue
@@ -3,12 +3,12 @@
Tags
- No tags yet.
+ No tags yet.
@@ -24,8 +24,9 @@
@@ -61,17 +62,24 @@ import SuggestionsPanel from './SuggestionsPanel.vue'
import TagRenameDialog from './TagRenameDialog.vue'
import FandomSetDialog from './FandomSetDialog.vue'
-const modal = useModalStore()
+// `host` is the tagging context — a store-like object exposing
+// current / currentImageId + removeTag/addExistingTag/createAndAdd/reloadTags
+// (+ optional close). Defaults to the modal store so the image modal is
+// unchanged; the Explore workspace passes its own host bound to the anchor,
+// reusing this panel verbatim for modal-parity tagging.
+const props = defineProps({ host: { type: Object, default: null } })
+const modalStore = useModalStore()
+const host = props.host || modalStore
const suggestions = useSuggestionsStore()
const router = useRouter()
const errorMsg = ref(null)
const tagInputRef = ref(null)
-// #5: clicking a tag chip's body leaves the modal and opens the gallery
-// filtered for that single tag (a fresh filter — the obvious "show me more
-// like this tag" move). Rename/set-fandom (kebab) and remove (✕) stay put.
+// #5: clicking a tag chip's body leaves the current surface and opens the
+// gallery filtered for that single tag (a fresh filter — the obvious "show me
+// more like this tag" move). Rename/set-fandom (kebab) and remove (✕) stay put.
async function onNavigate(tag) {
- await modal.close()
+ await host.close?.()
router.push({ name: 'gallery', query: { tag_id: String(tag.id) } })
}
@@ -82,17 +90,17 @@ function focusTagInput() { tagInputRef.value?.focus?.() }
async function onRemove(tagId) {
errorMsg.value = null
- try { await modal.removeTag(tagId) }
+ try { await host.removeTag(tagId) }
catch (e) { errorMsg.value = e.message }
}
async function onPickExisting(hit) {
errorMsg.value = null
- try { await modal.addExistingTag(hit.id) }
+ try { await host.addExistingTag(hit.id) }
catch (e) { errorMsg.value = e.message }
}
async function onPickNew(payload) {
errorMsg.value = null
- try { await modal.createAndAdd(payload) }
+ try { await host.createAndAdd(payload) }
catch (e) { errorMsg.value = e.message }
}
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
@@ -102,7 +110,7 @@ async function onAcceptSuggestion(s) {
errorMsg.value = null
try {
await suggestions.accept(s)
- await modal.reloadTags()
+ await host.reloadTags()
focusTagInput()
} catch (e) { errorMsg.value = e.message }
}
@@ -116,8 +124,8 @@ function openRename(tag) {
}
async function onRenamed() {
renameDialog.value = false
- // Reflect the new name in the modal's current tag list without a full reload.
- await modal.reloadTags()
+ // Reflect the new name in the current tag list without a full reload.
+ await host.reloadTags()
}
const fandomDialog = ref(false)
@@ -130,7 +138,7 @@ function openSetFandom(tag) {
async function onFandomUpdated() {
fandomDialog.value = false
// A fandom change can merge the tag away; reload to reflect the new state.
- await modal.reloadTags()
+ await host.reloadTags()
}
diff --git a/frontend/src/router.js b/frontend/src/router.js
index 78b484e..6079ccb 100644
--- a/frontend/src/router.js
+++ b/frontend/src/router.js
@@ -19,20 +19,21 @@ const routes = [
{ path: '/', redirect: FRONT_DOOR },
// FC-2: image backbone
- { path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase' } },
- { path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } },
- // Explore: anchor on an image, walk its visual neighbours, close tag-coverage
- // gaps across the cluster (#94). Optional anchor param — bare /explore (the
- // nav entry) lands on an empty state that points you at an image.
- { path: '/explore/:imageId?', name: 'explore', component: ExploreView, meta: { title: 'Explore' } },
+ { path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
+ { path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20 } },
+ // Explore: a 3-pane tagging workspace — walk an image's visual neighbours
+ // (left) while tagging the focused image (center viewer + modal-parity tag
+ // rail). Optional anchor param — the bare /explore nav entry SEEDS a random
+ // image so the tab kick-starts a rabbit hole. navOrder pins it after Gallery.
+ { path: '/explore/:imageId?', name: 'explore', component: ExploreView, meta: { title: 'Explore', navOrder: 25 } },
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
// the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below).
- { path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse' } },
+ { path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30 } },
// Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series browse — a nav entry (meta.title).
- { path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series' } },
+ { path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40 } },
// Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title).
@@ -40,7 +41,7 @@ const routes = [
// FC-3: subscription backbone — purely management (sources/downloads),
// distinct from the Browse hub.
- { path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
+ { path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50 } },
// Settings — config, pinned to the right of the nav (TopNav special-cases it).
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
diff --git a/frontend/src/stores/explore.js b/frontend/src/stores/explore.js
index 8a9d014..dd7dfaf 100644
--- a/frontend/src/stores/explore.js
+++ b/frontend/src/stores/explore.js
@@ -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/ 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,
}
})
diff --git a/frontend/src/views/ExploreView.vue b/frontend/src/views/ExploreView.vue
index 7f55830..f46fec0 100644
--- a/frontend/src/views/ExploreView.vue
+++ b/frontend/src/views/ExploreView.vue
@@ -1,26 +1,28 @@
-
-
-
+
+
+
+
+
Finding a random image to explore…
+
+
+
+
mdi-compass-outline
-
Explore by visual similarity
-
- Open any image and choose Explore similar, or pick one
- from the gallery to start walking its visual neighbours and closing
- tag-coverage gaps across the cluster.
-