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:
@@ -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.
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div class="fc-gaps">
|
||||
<h3 class="fc-gaps__title">Tag-coverage gaps</h3>
|
||||
<p class="fc-gaps__hint fc-muted">
|
||||
Tags most of this cluster shares but some images lack. Apply to close the
|
||||
gap across the stragglers.
|
||||
</p>
|
||||
|
||||
<div class="fc-gaps__threshold">
|
||||
<label class="text-caption fc-muted">
|
||||
Consensus ≥ {{ Math.round(threshold * 100) }}%
|
||||
</label>
|
||||
<v-slider
|
||||
v-model="threshold"
|
||||
:min="0.3" :max="0.95" :step="0.05"
|
||||
density="compact" hide-details color="accent"
|
||||
aria-label="Consensus threshold"
|
||||
@update:model-value="onThresholdInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="fc-gaps__state">
|
||||
<v-progress-circular indeterminate size="22" width="2" color="accent" />
|
||||
</div>
|
||||
<v-alert v-else-if="error" type="error" variant="tonal" density="compact" class="mt-2">
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
<p v-else-if="!store.clusterIds.length" class="fc-gaps__state fc-muted text-body-2">
|
||||
Anchor on an image with visual neighbours to see gaps.
|
||||
</p>
|
||||
<p v-else-if="!gaps.length" class="fc-gaps__state fc-muted text-body-2">
|
||||
No gaps at this threshold — this cluster is consistently tagged.
|
||||
</p>
|
||||
|
||||
<div v-else class="fc-gaps__list">
|
||||
<div v-for="g in gaps" :key="g.tag_id" class="fc-gap">
|
||||
<div class="fc-gap__head">
|
||||
<span class="fc-gap__name">{{ g.name }}</span>
|
||||
<span class="fc-gap__count fc-muted" :title="`${g.present_count} of ${g.total} images have this tag`">
|
||||
{{ g.present_count }}/{{ g.total }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-gap__missing">
|
||||
<img
|
||||
v-for="iid in g.missing_image_ids.slice(0, MISSING_PREVIEW)" :key="iid"
|
||||
:src="store.thumbById[iid]" alt="" loading="lazy"
|
||||
/>
|
||||
<span
|
||||
v-if="g.missing_image_ids.length > MISSING_PREVIEW"
|
||||
class="fc-muted text-caption"
|
||||
>+{{ g.missing_image_ids.length - MISSING_PREVIEW }}</span>
|
||||
</div>
|
||||
<v-btn
|
||||
size="small" color="accent" variant="tonal" rounded="pill"
|
||||
:loading="applyingId === g.tag_id"
|
||||
prepend-icon="mdi-tag-plus"
|
||||
@click="applyGap(g)"
|
||||
>Apply to {{ g.missing_image_ids.length }} missing</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useExploreStore } from '../../stores/explore.js'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useInflightToken } from '../../composables/useInflightToken.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
|
||||
const MISSING_PREVIEW = 8
|
||||
|
||||
const store = useExploreStore()
|
||||
const api = useApi()
|
||||
|
||||
const threshold = ref(0.6)
|
||||
const gaps = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const applyingId = ref(null)
|
||||
|
||||
const inflight = useInflightToken()
|
||||
let debounce = null
|
||||
|
||||
async function fetchGaps () {
|
||||
const ids = store.clusterIds
|
||||
if (ids.length < 2) { gaps.value = []; return }
|
||||
inflight.cancel()
|
||||
const t = inflight.claim()
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const body = await api.post('/api/images/cluster/tag-gaps', {
|
||||
body: { image_ids: ids, threshold: threshold.value },
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
gaps.value = body.gaps || []
|
||||
} catch (e) {
|
||||
if (t.isCurrent()) { error.value = e.message; gaps.value = [] }
|
||||
} finally {
|
||||
if (t.isCurrent()) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onThresholdInput () {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(fetchGaps, 350)
|
||||
}
|
||||
|
||||
// Re-evaluate gaps whenever the cluster changes (new anchor / new neighbours).
|
||||
watch(() => store.clusterIds.join(','), fetchGaps, { immediate: true })
|
||||
|
||||
async function applyGap (g) {
|
||||
applyingId.value = g.tag_id
|
||||
try {
|
||||
const res = await api.post('/api/tags/images/bulk/tags', {
|
||||
body: { image_ids: g.missing_image_ids, tag_id: g.tag_id, source: 'manual' },
|
||||
})
|
||||
toast({
|
||||
text: `Applied “${g.name}” to ${res.added_count ?? g.missing_image_ids.length} image(s)`,
|
||||
type: 'success',
|
||||
})
|
||||
await fetchGaps() // the gap shrinks/closes — refresh
|
||||
} catch (e) {
|
||||
toast({ text: `Couldn't apply “${g.name}”: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
applyingId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-gaps {
|
||||
padding: 14px; border-radius: 12px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.03);
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.10);
|
||||
}
|
||||
.fc-gaps__title {
|
||||
font-family: 'Fraunces', Georgia, serif; font-size: 16px; font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.fc-gaps__hint { font-size: 12px; margin-bottom: 10px; }
|
||||
.fc-gaps__threshold { margin-bottom: 8px; }
|
||||
.fc-gaps__state { display: flex; justify-content: center; padding: 20px 0; }
|
||||
.fc-gaps__list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.fc-gap {
|
||||
padding: 10px; border-radius: 10px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
.fc-gap__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
|
||||
.fc-gap__name { font-weight: 600; }
|
||||
.fc-gap__count { font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||
.fc-gap__missing { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.fc-gap__missing img {
|
||||
width: 36px; height: 36px; object-fit: cover; border-radius: 5px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
|
||||
+10
-9
@@ -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' } },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
+200
-110
@@ -1,26 +1,28 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-8 fc-explore">
|
||||
<!-- Empty state: no anchor (the bare /explore nav entry). -->
|
||||
<div v-if="!anchorId" class="fc-explore__empty">
|
||||
<div class="fc-ex">
|
||||
<!-- Seeding the bare /explore nav entry with a random image. -->
|
||||
<div v-if="!anchorId && seeding" class="fc-ex__center-state">
|
||||
<div class="fc-ex__spinner" />
|
||||
<p class="fc-muted">Finding a random image to explore…</p>
|
||||
</div>
|
||||
|
||||
<!-- Seed failed (e.g. empty library) — offer the gallery. -->
|
||||
<div v-else-if="!anchorId && seedError" class="fc-ex__center-state">
|
||||
<v-icon size="48" color="accent">mdi-compass-outline</v-icon>
|
||||
<h2 class="fc-explore__empty-title">Explore by visual similarity</h2>
|
||||
<p class="fc-explore__empty-body">
|
||||
Open any image and choose <strong>Explore similar</strong>, or pick one
|
||||
from the gallery to start walking its visual neighbours and closing
|
||||
tag-coverage gaps across the cluster.
|
||||
</p>
|
||||
<h2 class="fc-ex__empty-title">Nothing to explore yet</h2>
|
||||
<p class="fc-ex__empty-body">{{ seedError }}</p>
|
||||
<v-btn color="accent" variant="flat" rounded="pill" :to="{ name: 'gallery' }">
|
||||
Browse the gallery
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Breadcrumb of the walked path. -->
|
||||
<nav v-if="store.breadcrumb.length > 1" class="fc-explore__trail" aria-label="Explore path">
|
||||
<!-- Breadcrumb of the walked path + reseed control. -->
|
||||
<nav class="fc-ex__trail" aria-label="Explore path">
|
||||
<button
|
||||
v-for="(c, i) in store.breadcrumb" :key="c.id"
|
||||
class="fc-explore__crumb"
|
||||
:class="{ 'fc-explore__crumb--current': i === store.breadcrumb.length - 1 }"
|
||||
class="fc-ex__crumb"
|
||||
:class="{ 'fc-ex__crumb--current': i === store.breadcrumb.length - 1 }"
|
||||
type="button"
|
||||
:aria-current="i === store.breadcrumb.length - 1 ? 'page' : undefined"
|
||||
:title="`Step ${i + 1}`"
|
||||
@@ -28,175 +30,263 @@
|
||||
>
|
||||
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
||||
</button>
|
||||
<v-btn
|
||||
class="fc-ex__reseed" size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-shuffle-variant" :loading="seeding"
|
||||
@click="reseed"
|
||||
>Random image</v-btn>
|
||||
</nav>
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" class="my-4">
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" class="ma-3">
|
||||
Failed to load: {{ store.error }}
|
||||
</v-alert>
|
||||
|
||||
<div class="fc-explore__layout">
|
||||
<div class="fc-explore__main">
|
||||
<!-- Anchor -->
|
||||
<section v-if="store.anchor" class="fc-explore__anchor">
|
||||
<img
|
||||
:src="store.anchor.thumbnail_url" :alt="`Anchor image ${store.anchor.id}`"
|
||||
class="fc-explore__anchor-img"
|
||||
/>
|
||||
<div class="fc-explore__anchor-meta">
|
||||
<div class="fc-explore__anchor-title">
|
||||
{{ store.anchor.artist?.name || 'Unknown artist' }}
|
||||
</div>
|
||||
<div class="fc-explore__chips">
|
||||
<v-chip
|
||||
v-for="t in store.anchor.tags || []" :key="t.id"
|
||||
size="x-small" variant="tonal" :color="tagStore.colorFor(t.kind)"
|
||||
>{{ t.name }}</v-chip>
|
||||
<span v-if="!store.anchor.tags?.length" class="fc-muted text-caption">No tags yet.</span>
|
||||
</div>
|
||||
<v-btn
|
||||
size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-image-outline" @click="openInViewer(store.anchor.id)"
|
||||
>Open in viewer</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Neighbour grid -->
|
||||
<h3 class="fc-explore__section-title">
|
||||
Visual neighbours
|
||||
<!-- Three panes: neighbours | viewer | tag rail. The image modal,
|
||||
unfolded into a persistent surface so tagging while rabbit-holing is
|
||||
fast (operator concept 2026-06-26). -->
|
||||
<div class="fc-ex__panes">
|
||||
<!-- LEFT: visual neighbours; click to walk. -->
|
||||
<aside class="fc-ex__neighbors" aria-label="Visual neighbours">
|
||||
<h3 class="fc-ex__rail-title">
|
||||
Neighbours
|
||||
<span v-if="store.neighbors.length" class="fc-muted">({{ store.neighbors.length }})</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="store.loading && !store.neighbors.length" class="fc-explore__grid">
|
||||
<div v-for="n in 12" :key="n" class="fc-explore__skel" />
|
||||
<div v-if="store.loading && !store.neighbors.length" class="fc-ex__grid">
|
||||
<div v-for="n in 8" :key="n" class="fc-ex__skel" />
|
||||
</div>
|
||||
<div
|
||||
<p
|
||||
v-else-if="store.anchor && !store.anchor.has_embedding"
|
||||
class="fc-explore__note fc-muted"
|
||||
class="fc-ex__note fc-muted"
|
||||
>
|
||||
This image has no visual embedding yet (videos and not-yet-processed
|
||||
images), so there are no neighbours to walk.
|
||||
</div>
|
||||
<div v-else-if="!store.neighbors.length" class="fc-explore__note fc-muted">
|
||||
No visual embedding yet (videos / not-yet-processed images), so
|
||||
there are no neighbours to walk.
|
||||
</p>
|
||||
<p v-else-if="!store.neighbors.length" class="fc-ex__note fc-muted">
|
||||
No visual neighbours found.
|
||||
</div>
|
||||
<div v-else class="fc-explore__grid">
|
||||
</p>
|
||||
<div v-else class="fc-ex__grid">
|
||||
<button
|
||||
v-for="img in store.neighbors" :key="img.id"
|
||||
class="fc-explore__tile" type="button"
|
||||
class="fc-ex__tile" type="button"
|
||||
:title="`Walk to image ${img.id}`"
|
||||
@click="goTo(img.id)"
|
||||
>
|
||||
<img :src="img.thumbnail_url" :alt="`Neighbour ${img.id}`" loading="lazy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right rail: cluster tag-gap closing (#94c). -->
|
||||
<aside class="fc-explore__rail">
|
||||
<TagGapPanel />
|
||||
<!-- CENTER: the focused image (light viewer) + meta. -->
|
||||
<section class="fc-ex__viewer">
|
||||
<div class="fc-ex__canvas">
|
||||
<ImageCanvas
|
||||
v-if="store.anchor"
|
||||
:key="store.anchor.id"
|
||||
:src="store.anchor.image_url"
|
||||
:alt="`Image ${store.anchor.id}`"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="store.anchor" class="fc-ex__viewer-foot">
|
||||
<div class="fc-ex__artist">{{ store.anchor.artist?.name || 'Unknown artist' }}</div>
|
||||
<ImageMetaBar :image="store.anchor" />
|
||||
<v-btn
|
||||
size="small" variant="text" color="accent"
|
||||
prepend-icon="mdi-arrow-expand-all" @click="openInViewer(store.anchor.id)"
|
||||
>Open full viewer</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT: the modal's tag rail, hosted on the anchor. -->
|
||||
<aside class="fc-ex__rail" aria-label="Tags for the focused image">
|
||||
<TagPanel v-if="store.currentImageId != null" :host="store" />
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useExploreStore } from '../stores/explore.js'
|
||||
import { useTagStore } from '../stores/tags.js'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import TagGapPanel from '../components/explore/TagGapPanel.vue'
|
||||
import { isTextEntry } from '../utils/textEntry.js'
|
||||
import ImageCanvas from '../components/modal/ImageCanvas.vue'
|
||||
import ImageMetaBar from '../components/modal/ImageMetaBar.vue'
|
||||
import TagPanel from '../components/modal/TagPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const store = useExploreStore()
|
||||
const tagStore = useTagStore()
|
||||
const modal = useModalStore()
|
||||
|
||||
const anchorId = computed(() => route.params.imageId || null)
|
||||
const seeding = ref(false)
|
||||
const seedError = ref(null)
|
||||
|
||||
// The route is the source of truth — anchor on whatever the URL says, on mount
|
||||
// and on every param change (forward walk + breadcrumb backtrack both push the
|
||||
// route). Reset the walk when we leave the anchored state entirely.
|
||||
// The route is the source of truth. With an anchor, walk it; without one (the
|
||||
// bare /explore nav entry) seed a RANDOM image so the tab kick-starts a rabbit
|
||||
// hole instead of dead-ending on an empty state (operator-confirmed 2026-06-26).
|
||||
watch(
|
||||
anchorId,
|
||||
(id) => { if (id) store.anchorOn(id); else store.reset() },
|
||||
(id) => {
|
||||
if (id) { store.anchorOn(id); return }
|
||||
store.reset()
|
||||
seedRandom()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function seedRandom () {
|
||||
if (seeding.value) return
|
||||
seeding.value = true
|
||||
seedError.value = null
|
||||
try {
|
||||
const body = await api.get('/api/showcase', { params: { limit: 1 } })
|
||||
const img = body.images?.[0]
|
||||
if (!img) {
|
||||
seedError.value = 'Your library has no images to explore yet.'
|
||||
return
|
||||
}
|
||||
router.replace({ name: 'explore', params: { imageId: String(img.id) } })
|
||||
} catch (e) {
|
||||
seedError.value = e.message
|
||||
} finally {
|
||||
seeding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// "Random image" jumps to a fresh seed from anywhere in the walk.
|
||||
function reseed () {
|
||||
router.push({ name: 'explore' })
|
||||
// If already on bare /explore the param watch won't refire, so seed directly.
|
||||
if (!anchorId.value) seedRandom()
|
||||
}
|
||||
|
||||
function goTo (id) {
|
||||
if (Number(id) === Number(anchorId.value)) return
|
||||
router.push({ name: 'explore', params: { imageId: String(id) } })
|
||||
}
|
||||
|
||||
function openInViewer (id) { modal.open(id) }
|
||||
|
||||
// Modal-parity focus: T or "/" jumps to the tag input (the same shortcut the
|
||||
// image modal binds), unless the caret is already in a text field.
|
||||
function onKeyDown (ev) {
|
||||
if ((ev.key === 't' || ev.key === '/') && !isTextEntry(ev.target)) {
|
||||
const input = document.querySelector('.fc-tag-autocomplete input')
|
||||
if (input) { ev.preventDefault(); input.focus() }
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
.fc-explore__empty {
|
||||
max-width: 520px; margin: 64px auto; text-align: center;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
/* Full-height workspace under the sticky top nav. */
|
||||
.fc-ex {
|
||||
display: flex; flex-direction: column;
|
||||
height: calc(100vh - 64px);
|
||||
min-height: 0;
|
||||
}
|
||||
.fc-explore__empty-title { font-family: 'Fraunces', Georgia, serif; font-weight: 500; }
|
||||
.fc-explore__empty-body { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
.fc-explore__trail {
|
||||
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
||||
padding: 4px 0 12px;
|
||||
.fc-ex__center-state {
|
||||
margin: auto; max-width: 520px; text-align: center;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
padding: 32px;
|
||||
}
|
||||
.fc-explore__crumb {
|
||||
width: 44px; height: 44px; border-radius: 8px; overflow: hidden;
|
||||
.fc-ex__empty-title { font-family: 'Fraunces', Georgia, serif; font-weight: 500; }
|
||||
.fc-ex__empty-body { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
.fc-ex__trail {
|
||||
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
||||
padding: 8px 12px; flex: 0 0 auto;
|
||||
}
|
||||
.fc-ex__crumb {
|
||||
width: 40px; height: 40px; border-radius: 8px; overflow: hidden;
|
||||
border: 2px solid transparent; cursor: pointer; padding: 0;
|
||||
background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto;
|
||||
}
|
||||
.fc-explore__crumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-explore__crumb--current { border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-explore__crumb:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
|
||||
.fc-ex__crumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-ex__crumb--current { border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-ex__crumb:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
|
||||
.fc-ex__reseed { margin-left: auto; }
|
||||
|
||||
.fc-explore__layout { display: flex; gap: 20px; align-items: flex-start; }
|
||||
.fc-explore__main { flex: 1 1 auto; min-width: 0; }
|
||||
.fc-explore__rail { flex: 0 0 320px; position: sticky; top: 80px; }
|
||||
@media (max-width: 960px) {
|
||||
.fc-explore__layout { flex-direction: column; }
|
||||
.fc-explore__rail { flex-basis: auto; width: 100%; position: static; }
|
||||
/* The three panes fill the remaining height; each scrolls on its own. */
|
||||
.fc-ex__panes {
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr) var(--fc-side-w, 320px);
|
||||
gap: 1px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
|
||||
.fc-explore__anchor {
|
||||
display: flex; gap: 16px; margin-bottom: 20px;
|
||||
padding: 12px; border-radius: 12px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.03);
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.10);
|
||||
.fc-ex__neighbors {
|
||||
background: rgb(var(--v-theme-background));
|
||||
overflow-y: auto; padding: 12px;
|
||||
}
|
||||
.fc-explore__anchor-img {
|
||||
width: 160px; height: 160px; object-fit: cover; border-radius: 8px;
|
||||
background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto;
|
||||
.fc-ex__rail-title {
|
||||
font-size: 14px; font-weight: 600; margin: 2px 0 10px;
|
||||
}
|
||||
.fc-explore__anchor-meta { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.fc-explore__anchor-title { font-weight: 600; }
|
||||
.fc-explore__chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
|
||||
.fc-explore__section-title {
|
||||
font-size: 14px; font-weight: 600; margin: 4px 0 10px;
|
||||
}
|
||||
.fc-explore__grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
.fc-ex__grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.fc-explore__tile {
|
||||
.fc-ex__tile {
|
||||
aspect-ratio: 1; border-radius: 8px; overflow: hidden; padding: 0;
|
||||
border: 2px solid transparent; cursor: pointer;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-explore__tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.fc-explore__tile:hover { border-color: rgba(var(--v-theme-accent), 0.6); }
|
||||
.fc-explore__tile:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
|
||||
.fc-explore__skel {
|
||||
.fc-ex__tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.fc-ex__tile:hover { border-color: rgba(var(--v-theme-accent), 0.6); }
|
||||
.fc-ex__tile:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
|
||||
.fc-ex__skel {
|
||||
aspect-ratio: 1; border-radius: 8px;
|
||||
background: rgb(var(--v-theme-surface-light)); animation: fc-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.fc-explore__note { padding: 24px 0; }
|
||||
.fc-ex__note { padding: 16px 4px; }
|
||||
|
||||
/* Center viewer. */
|
||||
.fc-ex__viewer {
|
||||
background: rgb(20, 23, 26);
|
||||
display: flex; flex-direction: column; min-width: 0; min-height: 0;
|
||||
}
|
||||
.fc-ex__canvas { flex: 1 1 auto; display: flex; min-height: 0; min-width: 0; }
|
||||
.fc-ex__viewer-foot {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.fc-ex__artist { padding: 10px 16px 0; font-weight: 600; }
|
||||
|
||||
/* Right rail = the modal's tag panel, hosted on the anchor. */
|
||||
.fc-ex__rail {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fc-ex__spinner {
|
||||
width: 64px; height: 64px; border-radius: 50%;
|
||||
border: 5px solid rgb(var(--v-theme-accent), 0.16);
|
||||
border-top-color: rgb(var(--v-theme-accent));
|
||||
animation: fc-ex-spin 0.95s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
|
||||
}
|
||||
@keyframes fc-ex-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fc-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.9; } }
|
||||
@media (prefers-reduced-motion: reduce) { .fc-ex__spinner { animation-duration: 3s; } }
|
||||
|
||||
/* Narrow: stack viewer → tags → neighbours, page scrolls. */
|
||||
@media (max-width: 1100px) {
|
||||
.fc-ex { height: auto; }
|
||||
.fc-ex__panes {
|
||||
grid-template-columns: 1fr;
|
||||
background: transparent;
|
||||
}
|
||||
.fc-ex__viewer { order: -1; min-height: 60vh; }
|
||||
.fc-ex__neighbors { order: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user