From 2d1cddd9b7a5df394a792144d2fe76753be89bb3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 26 Jun 2026 01:15:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(explore):=203-pane=20tagging=20workspace?= =?UTF-8?q?=20=E2=80=94=20gallery=20|=20viewer=20|=20tag=20rail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/components/TopNav.vue | 10 +- .../src/components/explore/TagGapPanel.vue | 159 --------- .../src/components/modal/ImageMetaBar.vue | 6 +- .../src/components/modal/SuggestionsPanel.vue | 15 +- frontend/src/components/modal/TagPanel.vue | 40 ++- frontend/src/router.js | 19 +- frontend/src/stores/explore.js | 98 +++++- frontend/src/views/ExploreView.vue | 310 +++++++++++------- 8 files changed, 337 insertions(+), 320 deletions(-) delete mode 100644 frontend/src/components/explore/TagGapPanel.vue 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 @@ - - - - - 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 @@