feat(explore): 3-pane tagging workspace — gallery | viewer | tag rail
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

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:
2026-06-26 01:15:11 -04:00
parent 1aadf3267b
commit 2d1cddd9b7
8 changed files with 337 additions and 320 deletions
@@ -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']
@@ -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' })
+24 -16
View File
@@ -3,12 +3,12 @@
<h3 class="fc-tag-panel__title">Tags</h3>
<div class="fc-tag-panel__chips">
<TagChip
v-for="tag in modal.current?.tags || []"
v-for="tag in host.current?.tags || []"
:key="tag.id" :tag="tag"
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
@navigate="onNavigate"
/>
<span v-if="!modal.current?.tags?.length" class="text-caption">No tags yet.</span>
<span v-if="!host.current?.tags?.length" class="text-caption">No tags yet.</span>
</div>
<v-divider class="my-3" />
@@ -24,8 +24,9 @@
</v-alert>
<SuggestionsPanel
v-if="modal.currentImageId != null"
:image-id="modal.currentImageId"
v-if="host.currentImageId != null"
:image-id="host.currentImageId"
:host="host"
@accepted="focusTagInput"
/>
@@ -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()
}
</script>