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:
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user