Merge pull request 'Fandom count fix + Explore 3-pane workspace + modal rail layout' (#131) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m21s

This commit was merged in pull request #131.
This commit is contained in:
2026-06-26 08:24:26 -04:00
11 changed files with 421 additions and 362 deletions
+12 -13
View File
@@ -55,21 +55,20 @@ class TagDirectoryService:
fandom = aliased(Tag)
member = aliased(Tag)
# image_count aggregates the tag's images INCLUDING, for a fandom, every
# image carrying one of its characters (member.fandom_id == Tag.id) — the
# member subquery is empty for non-fandom tags, so they stay direct-only.
# DISTINCT so an image with both the fandom and ≥1 of its characters
# counts once; a correlated scalar subquery keeps it 1 row per tag with
# no group_by (the fandom self-join below is 1:1).
# image carrying one of its characters. `member` is the tag actually on
# the image; an image counts for the outer Tag if that applied tag IS the
# Tag (direct) OR is a character whose fandom_id is the Tag (the fandom
# leg). Both legs correlate the OUTER Tag.id at a SINGLE level — a nested
# `tag_id IN (SELECT ... WHERE member.fandom_id == Tag.id)` does NOT
# correlate and silently counts every fandom-character globally (~every
# tag collapses to the same inflated number). DISTINCT so an image with
# the fandom AND ≥1 of its characters counts once; a correlated scalar
# subquery keeps it 1 row per tag with no group_by.
count_col = (
select(func.count(image_tag.c.image_record_id.distinct()))
.where(
or_(
image_tag.c.tag_id == Tag.id,
image_tag.c.tag_id.in_(
select(member.id).where(member.fandom_id == Tag.id)
),
)
)
.select_from(image_tag)
.join(member, member.id == image_tag.c.tag_id)
.where(or_(image_tag.c.tag_id == Tag.id, member.fandom_id == Tag.id))
.correlate(Tag)
.scalar_subquery()
.label("image_count")
+7 -3
View File
@@ -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>
+28 -12
View File
@@ -1,7 +1,8 @@
<template>
<section v-if="img" class="fc-meta" aria-label="Image details">
<!-- #4a: dimensions / size / type already on the payload, shown nowhere
until now. -->
<!-- #4a: dimensions / size / type sit as a compact top block alongside a
small save action (operator-asked 2026-06-26) meta on the left, the
download control shrunk to a floppy-disk icon on the right. -->
<dl class="fc-meta__grid">
<div v-if="img.width && img.height" class="fc-meta__item">
<dt>Dimensions</dt><dd>{{ img.width }} × {{ img.height }}</dd>
@@ -14,21 +15,27 @@
</div>
</dl>
<!-- #4b: split Download default Download, chevron Copy link. Image-to-
clipboard is OFF the table on the plain-HTTP origin (rule 95), so the
secondary action copies the link TEXT, which works everywhere. -->
<v-btn-group divided density="comfortable" variant="tonal" color="accent" rounded="pill">
<v-btn prepend-icon="mdi-download" @click="download">Download</v-btn>
<!-- #4b: floppy-disk save icon = download; the kebab menu keeps Copy link.
Image-to-clipboard is OFF the table on the plain-HTTP origin (rule 95),
so the secondary action copies the link TEXT, which works everywhere. -->
<div class="fc-meta__actions">
<v-btn
icon="mdi-content-save" size="small" variant="tonal" color="accent"
aria-label="Download image" title="Download" @click="download"
/>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn v-bind="props" icon="mdi-chevron-down" aria-label="More download options" />
<v-btn
v-bind="props" icon="mdi-dots-vertical" size="small" variant="text"
aria-label="More download options"
/>
</template>
<v-list density="compact">
<v-list-item prepend-icon="mdi-link-variant" title="Copy link" @click="copyLink" />
<v-list-item prepend-icon="mdi-download" title="Download" @click="download" />
<v-list-item prepend-icon="mdi-content-save" title="Download" @click="download" />
</v-list>
</v-menu>
</v-btn-group>
</div>
</section>
</template>
@@ -38,8 +45,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']
@@ -79,11 +90,16 @@ async function copyLink () {
<style scoped>
.fc-meta {
padding: 14px 16px 0;
display: flex; flex-direction: column; gap: 10px;
display: flex; align-items: flex-start; gap: 12px;
}
.fc-meta__grid {
flex: 1 1 auto; min-width: 0;
display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 0;
}
.fc-meta__actions {
flex: 0 0 auto;
display: flex; align-items: center; gap: 2px;
}
.fc-meta__item { display: flex; flex-direction: column; }
.fc-meta__item dt {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
+27 -6
View File
@@ -73,12 +73,17 @@
</div>
<aside v-if="modal.current" class="fc-viewer__side">
<ImageMetaBar />
<ProvenancePanel />
<TagPanel />
<!-- Non-blocking: fetches its own similar set after the modal is up;
collapses silently if empty/slow/failed (see RelatedStrip). -->
<RelatedStrip />
<!-- Meta + provenance + tags + suggestions scroll together here -->
<div class="fc-viewer__side-main">
<ImageMetaBar />
<ProvenancePanel />
<TagPanel />
</div>
<!-- while Related is PINNED to the bottom of the rail so it stays
reachable no matter how long Tags/Suggestions run (operator-asked
2026-06-26). Non-blocking: fetches its own similar set; collapses
silently (and takes no footer space) if empty/slow/failed. -->
<RelatedStrip class="fc-viewer__related" />
</aside>
</div>
</div>
@@ -309,6 +314,18 @@ function nextFrame() {
width: var(--fc-side-w); flex-shrink: 0;
background: rgb(var(--v-theme-surface));
border-left: 1px solid rgb(var(--v-theme-surface-light));
/* Flex column: a scrolling main area + a pinned Related footer. */
display: flex; flex-direction: column; min-height: 0;
}
.fc-viewer__side-main {
flex: 1 1 auto; min-height: 0; overflow-y: auto;
}
.fc-viewer__related {
/* Pinned to the bottom; capped so a tall strip can't swallow the rail —
it scrolls internally past the cap. RelatedStrip's own border-top draws
the divider; it self-collapses (no space) when there's nothing to show. */
flex: 0 0 auto;
max-height: 45%;
overflow-y: auto;
}
@@ -339,6 +356,10 @@ function nextFrame() {
border-left: none;
border-top: 1px solid rgb(var(--v-theme-surface-light));
}
/* The whole body scrolls on mobile — don't nest scrolls or pin Related; let
the rail flow naturally (Related lands at the end). */
.fc-viewer__side-main { flex: none; overflow: visible; }
.fc-viewer__related { max-height: none; overflow: visible; }
/* Re-center the prev/next arrows over the 55vh image band (their base
top:50% would land on the scrolling panel); next uses the full width
now that the panel is below, not beside. Close + integrity badge keep
@@ -12,10 +12,9 @@
No suggestions above threshold.
</div>
<!-- Scroll-capped so a long suggestion set (the General bucket can run to
dozens) scrolls WITHIN this box instead of stretching the right rail
and shoving the Related strip below the fold. Mirrors the
ProvenancePanel cards/attachments treatment. -->
<!-- Flows in the rail's main scroll area; Related is pinned to the bottom
of the rail (ImageViewer side layout), so a long suggestion set no
longer needs an internal scroll cap to keep Related reachable. -->
<div v-else class="fc-suggestions__list">
<SuggestionsCategoryGroup
v-for="cat in peopleCats" :key="cat"
@@ -51,12 +50,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 +90,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 +104,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' })
@@ -130,13 +136,9 @@ async function onRemoveAlias(s) {
margin-bottom: 8px;
}
.fc-suggestions__list {
/* Cap the suggestion run so it scrolls internally rather than pushing the
Related strip off the bottom of the rail. Hairline scrollbar matching
ProvenancePanel's capped regions. */
max-height: 320px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
/* No internal scroll cap: the rail now scrolls suggestions in its single
main scroll area while Related is pinned to the bottom (ImageViewer side
layout), so suggestions flow naturally without a nested scrollbar. */
padding-right: 4px;
}
.fc-suggestions__skeleton { display: flex; flex-direction: column; gap: 8px; }
+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>
+10 -9
View File
@@ -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' } },
+80 -18
View File
@@ -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
View File
@@ -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>
+16 -1
View File
@@ -47,20 +47,35 @@ async def test_directory_fandom_counts_and_previews_via_characters(db):
char = Tag(name="Ichigo", kind=TagKind.character, fandom_id=fandom.id)
db.add(char)
await db.flush()
# A SECOND, unrelated fandom + character + image. The count for "Bleach"
# must NOT pick this up — guards against the count's fandom leg failing to
# correlate the outer tag and counting EVERY fandom-character globally
# (which inflates every card to the same number).
other_fandom = Tag(name="Naruto", kind=TagKind.fandom)
db.add(other_fandom)
await db.flush()
other_char = Tag(name="Sasuke", kind=TagKind.character, fandom_id=other_fandom.id)
db.add(other_char)
await db.flush()
# img0: character only; img1: fandom direct; img2: BOTH (dedup → counts once)
img0, img1, img2 = await _img(db, 0), await _img(db, 1), await _img(db, 2)
other_img = await _img(db, 3)
await db.execute(image_tag.insert().values([
{"image_record_id": img0.id, "tag_id": char.id, "source": "manual"},
{"image_record_id": img1.id, "tag_id": fandom.id, "source": "manual"},
{"image_record_id": img2.id, "tag_id": char.id, "source": "manual"},
{"image_record_id": img2.id, "tag_id": fandom.id, "source": "manual"},
{"image_record_id": other_img.id, "tag_id": other_char.id, "source": "manual"},
]))
await db.flush()
svc = TagDirectoryService(db)
page = await svc.list_tags(kind="fandom", q=None, cursor=None, limit=10)
card = next(c for c in page.cards if c["name"] == "Bleach")
assert card["image_count"] == 3
assert card["image_count"] == 3 # NOT 4 — Naruto's image must be excluded
assert len(card["preview_thumbnails"]) == 3
# The unrelated fandom counts only its own one image.
other = next(c for c in page.cards if c["name"] == "Naruto")
assert other["image_count"] == 1
@pytest.mark.asyncio