From 1aadf3267b643377315d7a769366a4a26937ea75 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 26 Jun 2026 00:51:56 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(tags):=20correct=20directory=20image=5F?= =?UTF-8?q?count=20=E2=80=94=20fandom=20leg=20must=20correlate=20the=20out?= =?UTF-8?q?er=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory card count regressed to a globally-inflated number (~every card showed the same ~469): the fandom leg used a doubly-nested correlated subquery — image_tag.tag_id IN (SELECT member.id WHERE member.fandom_id == Tag.id) — whose inner predicate did not correlate the outer Tag, so it matched EVERY character that has any fandom and counted all their images for every tag. The gallery scope and cleanup count were unaffected (they pass a literal tag id, a single-level subquery), which is why only the card diverged from the gallery. Rewrite the count as a single-level correlated scalar subquery: join `member` (the tag applied to the image) and match image_tag.tag_id == Tag.id (direct) OR member.fandom_id == Tag.id (a character of this fandom). Strengthen the directory test with a second unrelated fandom/character so a non-correlating fandom leg fails (count would read 4 instead of 3). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/services/tag_directory_service.py | 25 +++++++++---------- tests/test_tag_directory_service.py | 17 ++++++++++++- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/backend/app/services/tag_directory_service.py b/backend/app/services/tag_directory_service.py index 9d3bd58..49c55cc 100644 --- a/backend/app/services/tag_directory_service.py +++ b/backend/app/services/tag_directory_service.py @@ -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") diff --git a/tests/test_tag_directory_service.py b/tests/test_tag_directory_service.py index 13662bd..22d5a7d 100644 --- a/tests/test_tag_directory_service.py +++ b/tests/test_tag_directory_service.py @@ -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 From 2d1cddd9b7a5df394a792144d2fe76753be89bb3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 26 Jun 2026 01:15:11 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(explore):=203-pane=20tagging=20workspa?= =?UTF-8?q?ce=20=E2=80=94=20gallery=20|=20viewer=20|=20tag=20rail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks Explore from "anchor + neighbour grid + cluster tag-gap rail" into a persistent 3-pane workspace that unfolds the image modal so you can tag while rabbit-holing (operator concept 2026-06-26): - LEFT neighbour grid (larger thumbs), click = walk; breadcrumb retained. - CENTER light viewer — reuses ImageCanvas + ImageMetaBar(:image) for the focused image; "Open full viewer" still launches the overlay modal. - RIGHT the modal's TagPanel, hosted on the anchor for modal-parity tagging (chips, autocomplete, suggestions + Accept, fandom-on-chip, T/"/" focus). Reuse without destabilising the audited modal store: TagPanel and SuggestionsPanel gain an optional `host` prop (default = modal store, so the image modal is unchanged); the explore store implements the same small tag-CRUD surface (current/currentImageId + reloadTags/addExistingTag/ removeTag/createAndAdd) over the anchor. ImageMetaBar gains an optional `image` prop for the same reason. Drops the mass/cluster tagger (TagGapPanel deleted; clusterIds/thumbById removed) — per-image tagging feeds the per-tag reference-embedding centroid better than bulk ops. Nav: keep the Explore tab but bare /explore now SEEDS a random image (GET /api/showcase?limit=1 → /explore/:id) so the tab kick-starts a rabbit hole; explicit meta.navOrder pins nav order (Explore after Gallery) since router.getRoutes() doesn't preserve declaration order. Note: the backend cluster tag-gaps route/service (#94a) is now frontend-orphaned — left in place; flag for a separate cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/TopNav.vue | 10 +- .../src/components/explore/TagGapPanel.vue | 159 --------- .../src/components/modal/ImageMetaBar.vue | 6 +- .../src/components/modal/SuggestionsPanel.vue | 15 +- frontend/src/components/modal/TagPanel.vue | 40 ++- frontend/src/router.js | 19 +- frontend/src/stores/explore.js | 98 +++++- frontend/src/views/ExploreView.vue | 310 +++++++++++------- 8 files changed, 337 insertions(+), 320 deletions(-) delete mode 100644 frontend/src/components/explore/TagGapPanel.vue diff --git a/frontend/src/components/TopNav.vue b/frontend/src/components/TopNav.vue index b37514e..4c0028f 100644 --- a/frontend/src/components/TopNav.vue +++ b/frontend/src/components/TopNav.vue @@ -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. diff --git a/frontend/src/components/explore/TagGapPanel.vue b/frontend/src/components/explore/TagGapPanel.vue deleted file mode 100644 index f44546c..0000000 --- a/frontend/src/components/explore/TagGapPanel.vue +++ /dev/null @@ -1,159 +0,0 @@ - - - - - diff --git a/frontend/src/components/modal/ImageMetaBar.vue b/frontend/src/components/modal/ImageMetaBar.vue index b9629ad..5cf3482 100644 --- a/frontend/src/components/modal/ImageMetaBar.vue +++ b/frontend/src/components/modal/ImageMetaBar.vue @@ -38,8 +38,12 @@ import { useModalStore } from '../../stores/modal.js' import { copyText } from '../../utils/clipboard.js' import { toast } from '../../utils/toast.js' +// `image` lets a non-modal surface (the Explore workspace) render the same +// meta + download block for its anchor. Defaults to the modal store's current +// image so the image modal is unchanged. +const props = defineProps({ image: { type: Object, default: null } }) const modal = useModalStore() -const img = computed(() => modal.current) +const img = computed(() => props.image ?? modal.current) function humanSize (bytes) { const units = ['B', 'KB', 'MB', 'GB'] diff --git a/frontend/src/components/modal/SuggestionsPanel.vue b/frontend/src/components/modal/SuggestionsPanel.vue index 0e40779..d49325c 100644 --- a/frontend/src/components/modal/SuggestionsPanel.vue +++ b/frontend/src/components/modal/SuggestionsPanel.vue @@ -51,12 +51,19 @@ import { useModalStore } from '../../stores/modal.js' import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue' import AliasPickerDialog from './AliasPickerDialog.vue' -const props = defineProps({ imageId: { type: Number, required: true } }) +const props = defineProps({ + imageId: { type: Number, required: true }, + // The tagging host whose chip rail to refresh after an accept. Defaults to + // the modal store (image modal); the Explore workspace passes its anchor host + // so the same panel refreshes the right surface. See TagPanel. + host: { type: Object, default: null }, +}) // 'accepted' lets the parent return focus to the tag input after a suggestion is // applied (operator-asked 2026-06-08). const emit = defineEmits(['accepted']) const store = useSuggestionsStore() -const modal = useModalStore() +const modalStore = useModalStore() +const host = props.host || modalStore // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as // suggestion categories. Only 'character' remains as a people-style @@ -84,7 +91,7 @@ watch(() => props.imageId, (id) => { async function onAccept(s) { try { await store.accept(s) - await modal.reloadTags() + await host.reloadTags() emit('accepted') } catch (e) { toast({ text: `Accept failed: ${e.message}`, type: 'error' }) @@ -98,7 +105,7 @@ async function onAliasConfirm(canonicalTagId) { try { await store.aliasAccept(aliasTarget.value, canonicalTagId) aliasDialog.value = false - await modal.reloadTags() + await host.reloadTags() emit('accepted') } catch (e) { toast({ text: `Alias failed: ${e.message}`, type: 'error' }) diff --git a/frontend/src/components/modal/TagPanel.vue b/frontend/src/components/modal/TagPanel.vue index ff611c8..38febe9 100644 --- a/frontend/src/components/modal/TagPanel.vue +++ b/frontend/src/components/modal/TagPanel.vue @@ -3,12 +3,12 @@

Tags

- No tags yet. + No tags yet.
@@ -24,8 +24,9 @@ @@ -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() } diff --git a/frontend/src/router.js b/frontend/src/router.js index 78b484e..6079ccb 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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' } }, diff --git a/frontend/src/stores/explore.js b/frontend/src/stores/explore.js index 8a9d014..dd7dfaf 100644 --- a/frontend/src/stores/explore.js +++ b/frontend/src/stores/explore.js @@ -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/ 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, } }) diff --git a/frontend/src/views/ExploreView.vue b/frontend/src/views/ExploreView.vue index 7f55830..f46fec0 100644 --- a/frontend/src/views/ExploreView.vue +++ b/frontend/src/views/ExploreView.vue @@ -1,26 +1,28 @@ From 1728b43167058dd07d6010bd6104d282dedcfb8e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 26 Jun 2026 01:37:26 -0400 Subject: [PATCH 3/3] fix(modal): pin Related to rail bottom, floppy-disk download, drop suggestions cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-rail layout fixes (operator-flagged 2026-06-26 — the prior change wasn't the intended improvement): - Pin the Related strip to the BOTTOM of the rail: the side becomes a flex column with a scrolling main area (meta + provenance + tags + suggestions) and a pinned Related footer (capped at 45% of the rail, scrolls past that). Related now stays reachable no matter how long Tags/Suggestions run, and self-collapses (no footer space) when there's nothing to show. - Remove the 320px suggestions scroll cap (3fcc4ae) — it was a workaround "so Related stays reachable"; pinning Related is the proper fix, so suggestions flow in the single main scroll instead of a nested scrollbar. - Shrink the Download button to a floppy-disk save icon (mdi-content-save); the meta (dimensions/size/type) + save action now sit as a compact top block (meta left, icon right). Copy link moves into the adjacent kebab menu. ImageMetaBar is shared with the Explore center pane, so the compact save control applies there too (parity). Mobile (<=900px) keeps the single body scroll — no nested scroll, Related flows at the end. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/modal/ImageMetaBar.vue | 34 +++++++++++++------ frontend/src/components/modal/ImageViewer.vue | 33 ++++++++++++++---- .../src/components/modal/SuggestionsPanel.vue | 17 ++++------ 3 files changed, 56 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/modal/ImageMetaBar.vue b/frontend/src/components/modal/ImageMetaBar.vue index 5cf3482..7bbbd5b 100644 --- a/frontend/src/components/modal/ImageMetaBar.vue +++ b/frontend/src/components/modal/ImageMetaBar.vue @@ -1,7 +1,8 @@ @@ -83,11 +90,16 @@ async function copyLink () {