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) fandom = aliased(Tag)
member = aliased(Tag) member = aliased(Tag)
# image_count aggregates the tag's images INCLUDING, for a fandom, every # image_count aggregates the tag's images INCLUDING, for a fandom, every
# image carrying one of its characters (member.fandom_id == Tag.id) — the # image carrying one of its characters. `member` is the tag actually on
# member subquery is empty for non-fandom tags, so they stay direct-only. # the image; an image counts for the outer Tag if that applied tag IS the
# DISTINCT so an image with both the fandom and ≥1 of its characters # Tag (direct) OR is a character whose fandom_id is the Tag (the fandom
# counts once; a correlated scalar subquery keeps it 1 row per tag with # leg). Both legs correlate the OUTER Tag.id at a SINGLE level — a nested
# no group_by (the fandom self-join below is 1:1). # `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 = ( count_col = (
select(func.count(image_tag.c.image_record_id.distinct())) select(func.count(image_tag.c.image_record_id.distinct()))
.where( .select_from(image_tag)
or_( .join(member, member.id == image_tag.c.tag_id)
image_tag.c.tag_id == Tag.id, .where(or_(image_tag.c.tag_id == Tag.id, member.fandom_id == Tag.id))
image_tag.c.tag_id.in_(
select(member.id).where(member.fandom_id == Tag.id)
),
)
)
.correlate(Tag) .correlate(Tag)
.scalar_subquery() .scalar_subquery()
.label("image_count") .label("image_count")
+7 -3
View File
@@ -72,10 +72,14 @@ import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore() const system = useSystemStore()
onMounted(() => system.refreshHealth()) onMounted(() => system.refreshHealth())
// Same mechanism the old sidebar used: every route with a meta.title is a // Every route with a meta.title is a nav entry. Order by meta.navOrder —
// nav entry, in router declaration order. Auto-tracks future routes. // 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(() => 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, // Content links for the centered desktop row — everything EXCEPT Settings,
// which is config and gets pinned to the right edge instead. // 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> <template>
<section v-if="img" class="fc-meta" aria-label="Image details"> <section v-if="img" class="fc-meta" aria-label="Image details">
<!-- #4a: dimensions / size / type already on the payload, shown nowhere <!-- #4a: dimensions / size / type sit as a compact top block alongside a
until now. --> 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"> <dl class="fc-meta__grid">
<div v-if="img.width && img.height" class="fc-meta__item"> <div v-if="img.width && img.height" class="fc-meta__item">
<dt>Dimensions</dt><dd>{{ img.width }} × {{ img.height }}</dd> <dt>Dimensions</dt><dd>{{ img.width }} × {{ img.height }}</dd>
@@ -14,21 +15,27 @@
</div> </div>
</dl> </dl>
<!-- #4b: split Download default Download, chevron Copy link. Image-to- <!-- #4b: floppy-disk save icon = download; the kebab menu keeps Copy link.
clipboard is OFF the table on the plain-HTTP origin (rule 95), so the Image-to-clipboard is OFF the table on the plain-HTTP origin (rule 95),
secondary action copies the link TEXT, which works everywhere. --> so the secondary action copies the link TEXT, which works everywhere. -->
<v-btn-group divided density="comfortable" variant="tonal" color="accent" rounded="pill"> <div class="fc-meta__actions">
<v-btn prepend-icon="mdi-download" @click="download">Download</v-btn> <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"> <v-menu location="bottom end">
<template #activator="{ props }"> <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> </template>
<v-list density="compact"> <v-list density="compact">
<v-list-item prepend-icon="mdi-link-variant" title="Copy link" @click="copyLink" /> <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-list>
</v-menu> </v-menu>
</v-btn-group> </div>
</section> </section>
</template> </template>
@@ -38,8 +45,12 @@ import { useModalStore } from '../../stores/modal.js'
import { copyText } from '../../utils/clipboard.js' import { copyText } from '../../utils/clipboard.js'
import { toast } from '../../utils/toast.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 modal = useModalStore()
const img = computed(() => modal.current) const img = computed(() => props.image ?? modal.current)
function humanSize (bytes) { function humanSize (bytes) {
const units = ['B', 'KB', 'MB', 'GB'] const units = ['B', 'KB', 'MB', 'GB']
@@ -79,11 +90,16 @@ async function copyLink () {
<style scoped> <style scoped>
.fc-meta { .fc-meta {
padding: 14px 16px 0; padding: 14px 16px 0;
display: flex; flex-direction: column; gap: 10px; display: flex; align-items: flex-start; gap: 12px;
} }
.fc-meta__grid { .fc-meta__grid {
flex: 1 1 auto; min-width: 0;
display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 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 { display: flex; flex-direction: column; }
.fc-meta__item dt { .fc-meta__item dt {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
+27 -6
View File
@@ -73,12 +73,17 @@
</div> </div>
<aside v-if="modal.current" class="fc-viewer__side"> <aside v-if="modal.current" class="fc-viewer__side">
<ImageMetaBar /> <!-- Meta + provenance + tags + suggestions scroll together here -->
<ProvenancePanel /> <div class="fc-viewer__side-main">
<TagPanel /> <ImageMetaBar />
<!-- Non-blocking: fetches its own similar set after the modal is up; <ProvenancePanel />
collapses silently if empty/slow/failed (see RelatedStrip). --> <TagPanel />
<RelatedStrip /> </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> </aside>
</div> </div>
</div> </div>
@@ -309,6 +314,18 @@ function nextFrame() {
width: var(--fc-side-w); flex-shrink: 0; width: var(--fc-side-w); flex-shrink: 0;
background: rgb(var(--v-theme-surface)); background: rgb(var(--v-theme-surface));
border-left: 1px solid rgb(var(--v-theme-surface-light)); 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; overflow-y: auto;
} }
@@ -339,6 +356,10 @@ function nextFrame() {
border-left: none; border-left: none;
border-top: 1px solid rgb(var(--v-theme-surface-light)); 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 /* 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 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 now that the panel is below, not beside. Close + integrity badge keep
@@ -12,10 +12,9 @@
No suggestions above threshold. No suggestions above threshold.
</div> </div>
<!-- Scroll-capped so a long suggestion set (the General bucket can run to <!-- Flows in the rail's main scroll area; Related is pinned to the bottom
dozens) scrolls WITHIN this box instead of stretching the right rail of the rail (ImageViewer side layout), so a long suggestion set no
and shoving the Related strip below the fold. Mirrors the longer needs an internal scroll cap to keep Related reachable. -->
ProvenancePanel cards/attachments treatment. -->
<div v-else class="fc-suggestions__list"> <div v-else class="fc-suggestions__list">
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-for="cat in peopleCats" :key="cat" v-for="cat in peopleCats" :key="cat"
@@ -51,12 +50,19 @@ import { useModalStore } from '../../stores/modal.js'
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue' import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
import AliasPickerDialog from './AliasPickerDialog.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 // 'accepted' lets the parent return focus to the tag input after a suggestion is
// applied (operator-asked 2026-06-08). // applied (operator-asked 2026-06-08).
const emit = defineEmits(['accepted']) const emit = defineEmits(['accepted'])
const store = useSuggestionsStore() 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 // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
// suggestion categories. Only 'character' remains as a people-style // suggestion categories. Only 'character' remains as a people-style
@@ -84,7 +90,7 @@ watch(() => props.imageId, (id) => {
async function onAccept(s) { async function onAccept(s) {
try { try {
await store.accept(s) await store.accept(s)
await modal.reloadTags() await host.reloadTags()
emit('accepted') emit('accepted')
} catch (e) { } catch (e) {
toast({ text: `Accept failed: ${e.message}`, type: 'error' }) toast({ text: `Accept failed: ${e.message}`, type: 'error' })
@@ -98,7 +104,7 @@ async function onAliasConfirm(canonicalTagId) {
try { try {
await store.aliasAccept(aliasTarget.value, canonicalTagId) await store.aliasAccept(aliasTarget.value, canonicalTagId)
aliasDialog.value = false aliasDialog.value = false
await modal.reloadTags() await host.reloadTags()
emit('accepted') emit('accepted')
} catch (e) { } catch (e) {
toast({ text: `Alias failed: ${e.message}`, type: 'error' }) toast({ text: `Alias failed: ${e.message}`, type: 'error' })
@@ -130,13 +136,9 @@ async function onRemoveAlias(s) {
margin-bottom: 8px; margin-bottom: 8px;
} }
.fc-suggestions__list { .fc-suggestions__list {
/* Cap the suggestion run so it scrolls internally rather than pushing the /* No internal scroll cap: the rail now scrolls suggestions in its single
Related strip off the bottom of the rail. Hairline scrollbar matching main scroll area while Related is pinned to the bottom (ImageViewer side
ProvenancePanel's capped regions. */ layout), so suggestions flow naturally without a nested scrollbar. */
max-height: 320px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
padding-right: 4px; padding-right: 4px;
} }
.fc-suggestions__skeleton { display: flex; flex-direction: column; gap: 8px; } .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> <h3 class="fc-tag-panel__title">Tags</h3>
<div class="fc-tag-panel__chips"> <div class="fc-tag-panel__chips">
<TagChip <TagChip
v-for="tag in modal.current?.tags || []" v-for="tag in host.current?.tags || []"
:key="tag.id" :tag="tag" :key="tag.id" :tag="tag"
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom" @remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
@navigate="onNavigate" @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> </div>
<v-divider class="my-3" /> <v-divider class="my-3" />
@@ -24,8 +24,9 @@
</v-alert> </v-alert>
<SuggestionsPanel <SuggestionsPanel
v-if="modal.currentImageId != null" v-if="host.currentImageId != null"
:image-id="modal.currentImageId" :image-id="host.currentImageId"
:host="host"
@accepted="focusTagInput" @accepted="focusTagInput"
/> />
@@ -61,17 +62,24 @@ import SuggestionsPanel from './SuggestionsPanel.vue'
import TagRenameDialog from './TagRenameDialog.vue' import TagRenameDialog from './TagRenameDialog.vue'
import FandomSetDialog from './FandomSetDialog.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 suggestions = useSuggestionsStore()
const router = useRouter() const router = useRouter()
const errorMsg = ref(null) const errorMsg = ref(null)
const tagInputRef = ref(null) const tagInputRef = ref(null)
// #5: clicking a tag chip's body leaves the modal and opens the gallery // #5: clicking a tag chip's body leaves the current surface and opens the
// filtered for that single tag (a fresh filter — the obvious "show me more // gallery filtered for that single tag (a fresh filter — the obvious "show me
// like this tag" move). Rename/set-fandom (kebab) and remove (✕) stay put. // more like this tag" move). Rename/set-fandom (kebab) and remove (✕) stay put.
async function onNavigate(tag) { async function onNavigate(tag) {
await modal.close() await host.close?.()
router.push({ name: 'gallery', query: { tag_id: String(tag.id) } }) router.push({ name: 'gallery', query: { tag_id: String(tag.id) } })
} }
@@ -82,17 +90,17 @@ function focusTagInput() { tagInputRef.value?.focus?.() }
async function onRemove(tagId) { async function onRemove(tagId) {
errorMsg.value = null errorMsg.value = null
try { await modal.removeTag(tagId) } try { await host.removeTag(tagId) }
catch (e) { errorMsg.value = e.message } catch (e) { errorMsg.value = e.message }
} }
async function onPickExisting(hit) { async function onPickExisting(hit) {
errorMsg.value = null errorMsg.value = null
try { await modal.addExistingTag(hit.id) } try { await host.addExistingTag(hit.id) }
catch (e) { errorMsg.value = e.message } catch (e) { errorMsg.value = e.message }
} }
async function onPickNew(payload) { async function onPickNew(payload) {
errorMsg.value = null errorMsg.value = null
try { await modal.createAndAdd(payload) } try { await host.createAndAdd(payload) }
catch (e) { errorMsg.value = e.message } catch (e) { errorMsg.value = e.message }
} }
// A suggestion picked from the autocomplete dropdown runs the SAME path as the // A suggestion picked from the autocomplete dropdown runs the SAME path as the
@@ -102,7 +110,7 @@ async function onAcceptSuggestion(s) {
errorMsg.value = null errorMsg.value = null
try { try {
await suggestions.accept(s) await suggestions.accept(s)
await modal.reloadTags() await host.reloadTags()
focusTagInput() focusTagInput()
} catch (e) { errorMsg.value = e.message } } catch (e) { errorMsg.value = e.message }
} }
@@ -116,8 +124,8 @@ function openRename(tag) {
} }
async function onRenamed() { async function onRenamed() {
renameDialog.value = false renameDialog.value = false
// Reflect the new name in the modal's current tag list without a full reload. // Reflect the new name in the current tag list without a full reload.
await modal.reloadTags() await host.reloadTags()
} }
const fandomDialog = ref(false) const fandomDialog = ref(false)
@@ -130,7 +138,7 @@ function openSetFandom(tag) {
async function onFandomUpdated() { async function onFandomUpdated() {
fandomDialog.value = false fandomDialog.value = false
// A fandom change can merge the tag away; reload to reflect the new state. // A fandom change can merge the tag away; reload to reflect the new state.
await modal.reloadTags() await host.reloadTags()
} }
</script> </script>
+10 -9
View File
@@ -19,20 +19,21 @@ const routes = [
{ path: '/', redirect: FRONT_DOOR }, { path: '/', redirect: FRONT_DOOR },
// FC-2: image backbone // FC-2: image backbone
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase' } }, { path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } }, { path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20 } },
// Explore: anchor on an image, walk its visual neighbours, close tag-coverage // Explore: a 3-pane tagging workspace — walk an image's visual neighbours
// gaps across the cluster (#94). Optional anchor param — bare /explore (the // (left) while tagging the focused image (center viewer + modal-parity tag
// nav entry) lands on an empty state that points you at an image. // rail). Optional anchor param — the bare /explore nav entry SEEDS a random
{ path: '/explore/:imageId?', name: 'explore', component: ExploreView, meta: { title: 'Explore' } }, // 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 — // 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 // the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below). // 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). // Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView }, { path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series browse — a nav entry (meta.title). // 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). // Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView }, { path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title). // Series reader — immersive (no top nav, no meta.title).
@@ -40,7 +41,7 @@ const routes = [
// FC-3: subscription backbone — purely management (sources/downloads), // FC-3: subscription backbone — purely management (sources/downloads),
// distinct from the Browse hub. // 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). // Settings — config, pinned to the right of the nav (TopNav special-cases it).
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } }, { 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 { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useInflightToken } from '../composables/useInflightToken.js' import { useInflightToken } from '../composables/useInflightToken.js'
import { toast } from '../utils/toast.js'
// Explore (#94): anchor on an image, walk its visual neighbours (pgvector // Explore (#94): anchor on an image, walk its visual neighbours (pgvector
// SigLIP via /api/gallery/similar), keep an in-memory breadcrumb of the walked // 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 // path. The ROUTE (`/explore/:imageId`) is the source of truth for the anchor;
// (`/explore/:imageId`) is the source of truth for the anchor; the view calls // the view calls anchorOn() on every param change and the store reconciles the
// anchorOn() on every param change and the store reconciles the trail. // 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 const NEIGHBOR_LIMIT = 24
export const useExploreStore = defineStore('explore', () => { export const useExploreStore = defineStore('explore', () => {
@@ -69,24 +72,83 @@ export const useExploreStore = defineStore('explore', () => {
loading.value = false loading.value = false
} }
// The consensus set = the anchor plus its neighbours. // --- TagPanel "host" surface ---------------------------------------------
const clusterIds = computed(() => { // The anchor IS the current image (same /api/gallery/image/<id> payload the
const ids = anchor.value ? [anchor.value.id] : [] // modal uses), so these mirror the modal store's tag-CRUD, targeting the
return ids.concat(neighbors.value.map((n) => n.id)) // 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" async function reloadTags () {
// images without another fetch (they're already in the cluster). const id = anchor.value?.id
const thumbById = computed(() => { if (!id) return
const map = {} const tags = await api.get(`/api/images/${id}/tags`)
if (anchor.value) map[anchor.value.id] = anchor.value.thumbnail_url if (anchor.value && anchor.value.id === id) anchor.value.tags = tags
for (const n of neighbors.value) map[n.id] = n.thumbnail_url }
return map
}) 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 { return {
anchor, neighbors, breadcrumb, loading, error, anchor, neighbors, breadcrumb, loading, error, NEIGHBOR_LIMIT,
clusterIds, thumbById, NEIGHBOR_LIMIT,
anchorOn, reset, anchorOn, reset,
// host surface
current, currentImageId,
reloadTags, addExistingTag, removeTag, createAndAdd, close,
} }
}) })
+200 -110
View File
@@ -1,26 +1,28 @@
<template> <template>
<v-container fluid class="pt-2 pb-8 fc-explore"> <div class="fc-ex">
<!-- Empty state: no anchor (the bare /explore nav entry). --> <!-- Seeding the bare /explore nav entry with a random image. -->
<div v-if="!anchorId" class="fc-explore__empty"> <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> <v-icon size="48" color="accent">mdi-compass-outline</v-icon>
<h2 class="fc-explore__empty-title">Explore by visual similarity</h2> <h2 class="fc-ex__empty-title">Nothing to explore yet</h2>
<p class="fc-explore__empty-body"> <p class="fc-ex__empty-body">{{ seedError }}</p>
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>
<v-btn color="accent" variant="flat" rounded="pill" :to="{ name: 'gallery' }"> <v-btn color="accent" variant="flat" rounded="pill" :to="{ name: 'gallery' }">
Browse the gallery Browse the gallery
</v-btn> </v-btn>
</div> </div>
<template v-else> <template v-else>
<!-- Breadcrumb of the walked path. --> <!-- Breadcrumb of the walked path + reseed control. -->
<nav v-if="store.breadcrumb.length > 1" class="fc-explore__trail" aria-label="Explore path"> <nav class="fc-ex__trail" aria-label="Explore path">
<button <button
v-for="(c, i) in store.breadcrumb" :key="c.id" v-for="(c, i) in store.breadcrumb" :key="c.id"
class="fc-explore__crumb" class="fc-ex__crumb"
:class="{ 'fc-explore__crumb--current': i === store.breadcrumb.length - 1 }" :class="{ 'fc-ex__crumb--current': i === store.breadcrumb.length - 1 }"
type="button" type="button"
:aria-current="i === store.breadcrumb.length - 1 ? 'page' : undefined" :aria-current="i === store.breadcrumb.length - 1 ? 'page' : undefined"
:title="`Step ${i + 1}`" :title="`Step ${i + 1}`"
@@ -28,175 +30,263 @@
> >
<img :src="c.thumbnail_url" alt="" loading="lazy" /> <img :src="c.thumbnail_url" alt="" loading="lazy" />
</button> </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> </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 }} Failed to load: {{ store.error }}
</v-alert> </v-alert>
<div class="fc-explore__layout"> <!-- Three panes: neighbours | viewer | tag rail. The image modal,
<div class="fc-explore__main"> unfolded into a persistent surface so tagging while rabbit-holing is
<!-- Anchor --> fast (operator concept 2026-06-26). -->
<section v-if="store.anchor" class="fc-explore__anchor"> <div class="fc-ex__panes">
<img <!-- LEFT: visual neighbours; click to walk. -->
:src="store.anchor.thumbnail_url" :alt="`Anchor image ${store.anchor.id}`" <aside class="fc-ex__neighbors" aria-label="Visual neighbours">
class="fc-explore__anchor-img" <h3 class="fc-ex__rail-title">
/> Neighbours
<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
<span v-if="store.neighbors.length" class="fc-muted">({{ store.neighbors.length }})</span> <span v-if="store.neighbors.length" class="fc-muted">({{ store.neighbors.length }})</span>
</h3> </h3>
<div v-if="store.loading && !store.neighbors.length" class="fc-ex__grid">
<div v-if="store.loading && !store.neighbors.length" class="fc-explore__grid"> <div v-for="n in 8" :key="n" class="fc-ex__skel" />
<div v-for="n in 12" :key="n" class="fc-explore__skel" />
</div> </div>
<div <p
v-else-if="store.anchor && !store.anchor.has_embedding" 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 No visual embedding yet (videos / not-yet-processed images), so
images), so there are no neighbours to walk. there are no neighbours to walk.
</div> </p>
<div v-else-if="!store.neighbors.length" class="fc-explore__note fc-muted"> <p v-else-if="!store.neighbors.length" class="fc-ex__note fc-muted">
No visual neighbours found. No visual neighbours found.
</div> </p>
<div v-else class="fc-explore__grid"> <div v-else class="fc-ex__grid">
<button <button
v-for="img in store.neighbors" :key="img.id" 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}`" :title="`Walk to image ${img.id}`"
@click="goTo(img.id)" @click="goTo(img.id)"
> >
<img :src="img.thumbnail_url" :alt="`Neighbour ${img.id}`" loading="lazy" /> <img :src="img.thumbnail_url" :alt="`Neighbour ${img.id}`" loading="lazy" />
</button> </button>
</div> </div>
</div> </aside>
<!-- Right rail: cluster tag-gap closing (#94c). --> <!-- CENTER: the focused image (light viewer) + meta. -->
<aside class="fc-explore__rail"> <section class="fc-ex__viewer">
<TagGapPanel /> <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> </aside>
</div> </div>
</template> </template>
</v-container> </div>
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useApi } from '../composables/useApi.js'
import { useExploreStore } from '../stores/explore.js' import { useExploreStore } from '../stores/explore.js'
import { useTagStore } from '../stores/tags.js'
import { useModalStore } from '../stores/modal.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 route = useRoute()
const router = useRouter() const router = useRouter()
const api = useApi()
const store = useExploreStore() const store = useExploreStore()
const tagStore = useTagStore()
const modal = useModalStore() const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null) 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 // The route is the source of truth. With an anchor, walk it; without one (the
// and on every param change (forward walk + breadcrumb backtrack both push the // bare /explore nav entry) seed a RANDOM image so the tab kick-starts a rabbit
// route). Reset the walk when we leave the anchored state entirely. // hole instead of dead-ending on an empty state (operator-confirmed 2026-06-26).
watch( watch(
anchorId, anchorId,
(id) => { if (id) store.anchorOn(id); else store.reset() }, (id) => {
if (id) { store.anchorOn(id); return }
store.reset()
seedRandom()
},
{ immediate: true }, { 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) { function goTo (id) {
if (Number(id) === Number(anchorId.value)) return if (Number(id) === Number(anchorId.value)) return
router.push({ name: 'explore', params: { imageId: String(id) } }) router.push({ name: 'explore', params: { imageId: String(id) } })
} }
function openInViewer (id) { modal.open(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> </script>
<style scoped> <style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); } .fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-explore__empty { /* Full-height workspace under the sticky top nav. */
max-width: 520px; margin: 64px auto; text-align: center; .fc-ex {
display: flex; flex-direction: column; align-items: center; gap: 14px; 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 { .fc-ex__center-state {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: auto; max-width: 520px; text-align: center;
padding: 4px 0 12px; display: flex; flex-direction: column; align-items: center; gap: 14px;
padding: 32px;
} }
.fc-explore__crumb { .fc-ex__empty-title { font-family: 'Fraunces', Georgia, serif; font-weight: 500; }
width: 44px; height: 44px; border-radius: 8px; overflow: hidden; .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; border: 2px solid transparent; cursor: pointer; padding: 0;
background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto; background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto;
} }
.fc-explore__crumb img { width: 100%; height: 100%; object-fit: cover; } .fc-ex__crumb img { width: 100%; height: 100%; object-fit: cover; }
.fc-explore__crumb--current { border-color: rgb(var(--v-theme-accent)); } .fc-ex__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: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; } /* The three panes fill the remaining height; each scrolls on its own. */
.fc-explore__main { flex: 1 1 auto; min-width: 0; } .fc-ex__panes {
.fc-explore__rail { flex: 0 0 320px; position: sticky; top: 80px; } flex: 1 1 auto; min-height: 0;
@media (max-width: 960px) { display: grid;
.fc-explore__layout { flex-direction: column; } grid-template-columns: 340px minmax(0, 1fr) var(--fc-side-w, 320px);
.fc-explore__rail { flex-basis: auto; width: 100%; position: static; } gap: 1px;
background: rgb(var(--v-theme-surface-light));
} }
.fc-explore__anchor { .fc-ex__neighbors {
display: flex; gap: 16px; margin-bottom: 20px; background: rgb(var(--v-theme-background));
padding: 12px; border-radius: 12px; overflow-y: auto; padding: 12px;
background: rgba(var(--v-theme-on-surface), 0.03);
border: 1px solid rgba(var(--v-theme-on-surface), 0.10);
} }
.fc-explore__anchor-img { .fc-ex__rail-title {
width: 160px; height: 160px; object-fit: cover; border-radius: 8px; font-size: 14px; font-weight: 600; margin: 2px 0 10px;
background: rgb(var(--v-theme-surface-light)); flex: 0 0 auto;
} }
.fc-explore__anchor-meta { display: flex; flex-direction: column; gap: 8px; min-width: 0; } .fc-ex__grid {
.fc-explore__anchor-title { font-weight: 600; } display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
.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));
gap: 8px; gap: 8px;
} }
.fc-explore__tile { .fc-ex__tile {
aspect-ratio: 1; border-radius: 8px; overflow: hidden; padding: 0; aspect-ratio: 1; border-radius: 8px; overflow: hidden; padding: 0;
border: 2px solid transparent; cursor: pointer; border: 2px solid transparent; cursor: pointer;
background: rgb(var(--v-theme-surface-light)); background: rgb(var(--v-theme-surface-light));
} }
.fc-explore__tile img { width: 100%; height: 100%; object-fit: cover; display: block; } .fc-ex__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-ex__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-ex__tile:focus-visible { outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; }
.fc-explore__skel { .fc-ex__skel {
aspect-ratio: 1; border-radius: 8px; aspect-ratio: 1; border-radius: 8px;
background: rgb(var(--v-theme-surface-light)); animation: fc-pulse 1.4s ease-in-out infinite; 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; } } @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> </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) char = Tag(name="Ichigo", kind=TagKind.character, fandom_id=fandom.id)
db.add(char) db.add(char)
await db.flush() 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: 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) 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([ await db.execute(image_tag.insert().values([
{"image_record_id": img0.id, "tag_id": char.id, "source": "manual"}, {"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": 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": char.id, "source": "manual"},
{"image_record_id": img2.id, "tag_id": fandom.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() await db.flush()
svc = TagDirectoryService(db) svc = TagDirectoryService(db)
page = await svc.list_tags(kind="fandom", q=None, cursor=None, limit=10) 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") 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 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 @pytest.mark.asyncio